Compare commits
No commits in common. "main" and "pr/docs-hw-decode-correction" have entirely different histories.
main
...
pr/docs-hw
|
|
@ -1,58 +0,0 @@
|
|||
# Builds FilmCraft on FreeBSD, inside a FreeBSD VM on a Linux runner (GitHub has no FreeBSD
|
||||
# runners), then runs packaging/freebsd/package.sh, the script the release job runs, and checks
|
||||
# the tarball. A broken FreeBSD build or package fails here before it can fail a release. Nothing
|
||||
# is uploaded and no secrets are used. (The test suite isn't run here yet: the ffmpeg-oracle and
|
||||
# golden-image tests need tools and a GPU the VM doesn't have.)
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build takes an hour or more, so it runs nightly on main rather than on every push or
|
||||
# dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "37 2 * * *"
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
# The same packages as release.yml's freebsd job: winit/wgpu (X11, Wayland, Vulkan,
|
||||
# Mesa), the file dialogs (GTK 3), fonts, and audio (cpal over ALSA: alsa-lib).
|
||||
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 "*"
|
||||
rustc --version
|
||||
export CARGO_TARGET_DIR=/var/tmp/filmcraft-target CARGO_BUILD_JOBS=4
|
||||
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$'
|
||||
|
|
@ -19,5 +19,39 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
lint:
|
||||
# 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
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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: 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 '<Icon Id="[^"]*"' packaging/windows/filmcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.filmcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.filmcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.filmcraft.yml')); assert m['id'] == 'ai.storyteller.filmcraft' and m['command'] == 'filmcraft'"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "FilmCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# All jobs 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.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
|
|
@ -11,33 +17,274 @@ on:
|
|||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
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
|
||||
environment: release
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
environment: release
|
||||
env:
|
||||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
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 }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
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@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
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 }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
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 }}
|
||||
environment: release
|
||||
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@v4
|
||||
- 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
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
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)
|
||||
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
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
env:
|
||||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
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
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
# 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 }}
|
||||
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 }}
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, 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@v4
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
|
|
@ -26,10 +26,70 @@ concurrency:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
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
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
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@v4
|
||||
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@v4
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -5,4 +5,3 @@ plan/
|
|||
/log
|
||||
.DS_Store
|
||||
.claude/worktrees/
|
||||
__pycache__/
|
||||
|
|
|
|||
29
AGENTS.md
29
AGENTS.md
|
|
@ -21,7 +21,7 @@ panic path, and fix a crash before building on top of it. The cross-app standard
|
|||
code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason.
|
||||
3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is
|
||||
`crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware
|
||||
video decoding through VideoToolbox, Media Foundation / Direct3D 11, VA-API and NVDEC; hardware encoding) and does nothing else. It
|
||||
video decoding through VideoToolbox; later encoding and other OSes) and does nothing else. It
|
||||
uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a
|
||||
`// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the
|
||||
pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)).
|
||||
|
|
@ -92,13 +92,6 @@ reproduce an image (for example, point lists traced from someone else's icon).
|
|||
need a sidecar and an `ATTRIBUTION.md` row (licence `LicenseRef-ArtCraft-Trademark`). No other
|
||||
non-open asset is allowed, and this exception never covers third-party marks (Adobe, Discord, GitHub
|
||||
and other logos stay out; draw a generic icon instead).
|
||||
9. **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo.** Don't commit new font files (the small Latin UI fonts already in `assets/fonts/` stay). A
|
||||
font FilmCraft needs is added to craft-fonts, which the app reads through the optional build input
|
||||
`CRAFT_FONTS_DIR=<absolute path of a craft-fonts checkout>` (`crates/text/build.rs` embeds the fonts in its
|
||||
`fonts/manifest.txt` as `filmcraft_text::fonts::CRAFT_FONTS`; unset, it is empty and the app uses its
|
||||
bundled and system fonts). Never add craft-fonts to a `Cargo.toml`. Code using `CRAFT_FONTS` must work
|
||||
when it is empty. Standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
## 2. Clean-room code
|
||||
|
||||
|
|
@ -127,7 +120,6 @@ See [§0](#0-never-crash). It outranks every other engineering rule.
|
|||
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
|
||||
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
|
||||
|
||||
|
||||
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
|
||||
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
|
||||
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
|
||||
|
|
@ -142,22 +134,3 @@ as PhotoCraft does with `cargo xtask corpus`.
|
|||
- [docs/agents.md](docs/agents.md): driving FilmCraft over MCP / the control channel, and the agent work loop
|
||||
- [docs/control-protocol.md](docs/control-protocol.md): control-channel method reference
|
||||
- [ATTRIBUTION.md](ATTRIBUTION.md): asset index
|
||||
- [ROADMAP.md](ROADMAP.md), [docs/target-app-parity.md](docs/target-app-parity.md), [docs/gaps.md](docs/gaps.md): stage, parity with Premiere Pro and the ranked work list (craftrules `standards/progress-docs.md`)
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
|
|
|||
|
|
@ -9,13 +9,6 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
|||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | FilmCraft contributors | Original work: clean-room Italian interface translations | MIT OR Apache-2.0 |
|
||||
| `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/ko.tsv` | FilmCraft contributors | Original work: clean-room Korean 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`) |
|
||||
| `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/Inter-SemiBold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
|
|
@ -33,8 +26,6 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
|||
| `docs/images/filmcraft-effects.png` | FilmCraft contributors | Original work: FilmCraft screenshot crop; NASA Earth Views (US Government work, public domain) | MIT OR Apache-2.0; footage public domain |
|
||||
| `docs/images/filmcraft-timeline.png` | FilmCraft contributors | Original work: FilmCraft UI crop; thumbnails from Night of the Living Dead (1968) and Carnival of Souls (1962), CC0 Chopin waveform | MIT OR Apache-2.0; film frames public domain |
|
||||
| `assets/app-icon/filmcraft.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; canonical vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: FilmCraft macOS DMG window background, derived from the FilmCraft app icon (`assets/app-icon/filmcraft.svg`); rendered to `background.tiff` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
|
||||
| `assets/app-icon/filmcraft-small.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; lighter vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `assets/app-icon/filmcraft-1024.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; 1024 px render | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `assets/app-icon/filmcraft-macos-512.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; macOS runtime Dock icon (512 px) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
|
|
@ -108,7 +99,6 @@ is traced or derived from Adobe artwork.
|
|||
| Asset | Where |
|
||||
|---|---|
|
||||
| UI icons (tools, transport, panels, header) | `crates/ui-egui/src/icons.rs`: vector paths on a 16×16 grid |
|
||||
| Source and Program range braces and trim hover cues | `crates/ui-egui/src/panels/monitor.rs` and `source_range.rs`: original generic vector shapes by FilmCraft contributors, MIT OR Apache-2.0; no third-party artwork |
|
||||
| Align / distribute / paragraph-alignment glyphs in the graphics panel | `crates/ui-egui/src/panels/graphics.rs` (`align_glyph`): bars and lines drawn with the egui painter |
|
||||
| Demo footage (ocean sunset, aurora, city night, dunes, forest, plasma), bars and tone, counting leader, colour matte | `crates/media/src/generators.rs` |
|
||||
| Built-in graphics templates (Lower Third – Slab / Rule, Ticker – Crawl, Title – Centered / Boxed, End Credits – Roll, Callout – Pointer / Tag) and their placeholder text | `crates/project/src/gtemplate.rs` (`builtin_templates`): original designs defined in code with the bundled Inter font; thumbnails are rendered at runtime, no template, image or font files |
|
||||
|
|
@ -116,21 +106,6 @@ is traced or derived from Adobe artwork.
|
|||
| Lumetri Presets (Effects panel: Cinematic, Film Emulation, Monochrome, Technical) and their preview picture | `crates/render/src/lumetri_presets.rs`: Lumetri parameter values chosen by contributors, descriptive names, and a procedural preview picture (dusk sky, ridges, lake, grey ramp, colour chips) |
|
||||
| Colour palette and layout metrics | `crates/ui-egui/src/theme.rs`: colour values and sizes only; no artwork |
|
||||
|
||||
## Optional build input: craft-fonts
|
||||
|
||||
These fonts are **not files in this repository**. Builds made with the optional build input
|
||||
`CRAFT_FONTS_DIR` (all official releases; see [docs/contributing.md](docs/contributing.md#building-with-craft-fonts))
|
||||
embed them from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), where each file,
|
||||
its author, pinned upstream source and licence text are listed in its
|
||||
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). Release packages
|
||||
carry each font's licence as `OFL-<family>.txt`.
|
||||
|
||||
| Font | Use in FilmCraft | Licence |
|
||||
|---|---|---|
|
||||
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 |
|
||||
| Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
|
||||
| BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
|
||||
|
||||
## First-party brand marks
|
||||
|
||||
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and
|
||||
|
|
@ -149,15 +124,6 @@ remove them (AGENTS.md §1.8).
|
|||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
|
||||
## Third-party source code
|
||||
|
||||
Source files that are transcribed from third-party code rather than written from scratch. Each keeps
|
||||
the original copyright and permission notice in its header.
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/platform/src/nvenc/ffi.rs` | NVIDIA Corporation (`nvEncodeAPI.h`); FilmCraft contributors (Rust transcription) | Structures (including `NV_ENC_CONFIG_HEVC` and its bit-field masks, `NV_ENC_PIC_PARAMS_HEVC`, `NV_ENC_SEI_PAYLOAD`, `NV_ENC_TIME_CODE`), function table, codec / profile GUIDs (H.264, HEVC Main, HEVC Main 10) and constants (`NV_ENC_LEVEL_HEVC_*`, tier, CU size, `NV_ENC_BUFFER_FORMAT_YUV420_10BIT`, `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, BT.2020 / PQ / HLG VUI codes) transcribed from `nvEncodeAPI.h`, NVIDIA Video Codec SDK, API 12.1 (https://developer.nvidia.com/video-codec-sdk) | MIT (Copyright (c) 2010-2023 NVIDIA Corporation; notice kept in the file header) |
|
||||
|
||||
## Downloaded at runtime
|
||||
|
||||
These files are never in the repository or the app bundle. FilmCraft downloads them into the
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
# FilmCraft — instructions for agents
|
||||
|
||||
FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor targeting Adobe Premiere Pro parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../pdfcraft` (Acrobat) and `../vectorcraft` (Illustrator; formerly DrawCraft), with the same conventions.
|
||||
FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor targeting Adobe Premiere Pro parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../printcraft` (Acrobat) and `../drawcraft` (Illustrator), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
`plan/` is maintainer-local (gitignored, not in the repo). Each step names the public equivalent to use if you don't have it.
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). Public: [`ROADMAP.md`](ROADMAP.md), then [`docs/gaps.md`](docs/gaps.md) (ranked shortfalls) and [`docs/roadmap.md`](docs/roadmap.md) (current focus).
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). Public: [`ROADMAP.md`](ROADMAP.md).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Public: [`docs/architecture.md`](docs/architecture.md), [`docs/contributing.md`](docs/contributing.md), [`docs/testing.md`](docs/testing.md). Visual/behaviour reference: `plan/premiere/` (local only, never committed).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7; public summary: [`docs/agents.md`](docs/agents.md) §5–7). Don't stop to ask unless it lists the decision as the user's.
|
||||
|
||||
## Non-negotiables
|
||||
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.**
|
||||
- **Never crash** ([`AGENTS.md`](AGENTS.md) §0, standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)). People depend on FilmCraft; a crash loses their work, so this outranks feature work. No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` or `unsafe` outside tests: return `Result<T, E>` and propagate with `?` (`ok_or`, `let … else`, `if let`). Treat every number from media, project files, presets or CLI/MCP/control params as hostile: `get()` instead of `[i]`, checked/saturating math, no division by possibly-zero values, capped allocations, bounded recursion. `lock().unwrap_or_else(PoisonError::into_inner)`; background jobs run under `catch_unwind`. The panic hook + UI `catch_unwind` (`crates/ui-egui/src/crash.rs`) are the last-resort guard, not a licence. Every crash fix gets a regression test that panicked before. In particular (§1): never use Adobe iconography, images or any other Adobe asset. Every asset must be openly licensed (OSS / public domain / Creative Commons, or original work by a contributor) and must have a `<file>.attribution` sidecar plus an entry in `ATTRIBUTION.md`. `cargo xtask assets` enforces this.
|
||||
- **Fonts** live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo (no new font files here; [`AGENTS.md`](AGENTS.md) §1.9, standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Build with them: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run -p filmcraft` (`crates/text/build.rs` embeds them as `filmcraft_text::fonts::CRAFT_FONTS`, used for Japanese in the UI and titles). Unset, `CRAFT_FONTS` is empty and everything must still build, test and run; never add craft-fonts to a `Cargo.toml`. Tests on its glyphs skip when it is empty, so run the gates with and without `CRAFT_FONTS_DIR` when you touch fonts.
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, LUTs, fonts. Never copy GPL/LGPL/AGPL code (FFmpeg, x264, x265, MLT, Kdenlive, Shotcut, Olive, LAME…). ffmpeg/ffprobe run only as external test oracles / fixture generators — never linked or shipped.
|
||||
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback.
|
||||
- **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`.
|
||||
|
|
|
|||
|
|
@ -992,16 +992,6 @@ dependencies = [
|
|||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpu-time"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
|
|
@ -1011,15 +1001,6 @@ 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"
|
||||
|
|
@ -1674,40 +1655,29 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"eframe",
|
||||
"egui",
|
||||
"filmcraft-engine",
|
||||
"filmcraft-export",
|
||||
"filmcraft-gpu",
|
||||
"filmcraft-platform",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-time",
|
||||
"filmcraft-ui-egui",
|
||||
"glob",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"pollster 0.4.0",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1717,32 +1687,22 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-apv"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
|
|
@ -1758,7 +1718,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1767,23 +1727,16 @@ dependencies = [
|
|||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-avi"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
|
|
@ -1796,25 +1749,20 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-engine",
|
||||
"filmcraft-export",
|
||||
"filmcraft-gpu",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mpegts",
|
||||
"filmcraft-mxf",
|
||||
"filmcraft-ogg",
|
||||
"filmcraft-platform",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
|
@ -1822,13 +1770,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
"filmcraft-apv",
|
||||
"filmcraft-av1",
|
||||
"filmcraft-avi",
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-color",
|
||||
"filmcraft-dnx",
|
||||
|
|
@ -1838,7 +1784,6 @@ dependencies = [
|
|||
"filmcraft-isobmff",
|
||||
"filmcraft-matroska",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mpa",
|
||||
"filmcraft-mpeg2v",
|
||||
"filmcraft-mpegts",
|
||||
"filmcraft-mxf",
|
||||
|
|
@ -1847,12 +1792,9 @@ dependencies = [
|
|||
"filmcraft-prores",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"filmcraft-vp8",
|
||||
"filmcraft-vp9",
|
||||
"image",
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
|
|
@ -1860,24 +1802,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dng"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"filmcraft-dng",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1887,9 +1820,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-time",
|
||||
|
|
@ -1900,20 +1832,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"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",
|
||||
|
|
@ -1923,7 +1853,6 @@ dependencies = [
|
|||
"filmcraft-testkit",
|
||||
"filmcraft-text",
|
||||
"filmcraft-time",
|
||||
"filmcraft-tts",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
|
|
@ -1934,15 +1863,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-apv",
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-dnx",
|
||||
"filmcraft-flac",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-h264enc",
|
||||
|
|
@ -1963,23 +1890,13 @@ dependencies = [
|
|||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-flac"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-time",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -1987,7 +1904,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
|
|
@ -1997,7 +1914,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -2005,7 +1922,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -2022,17 +1939,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-time",
|
||||
"image",
|
||||
"log",
|
||||
"pollster 0.4.0",
|
||||
"rayon",
|
||||
|
|
@ -2041,7 +1956,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2051,7 +1966,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2061,7 +1976,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2071,7 +1986,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
|
|
@ -2088,7 +2003,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2099,7 +2014,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
|
@ -2107,32 +2022,20 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-dng",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"image",
|
||||
"png",
|
||||
"rayon",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpa"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
|
|
@ -2141,7 +2044,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -2149,54 +2052,46 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-platform"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-h264",
|
||||
"filmcraft-hevc",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"libloading",
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-media",
|
||||
"objc2-core-video",
|
||||
"objc2-video-toolbox",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
|
|
@ -2210,7 +2105,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2220,7 +2115,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
|
|
@ -2240,7 +2135,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
|
|
@ -2249,7 +2144,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
|
|
@ -2267,59 +2162,34 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
"skrifa",
|
||||
"unicode-bidi",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-tts"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-tts-text",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-tts-text"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"craft-ui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
|
|
@ -2328,7 +2198,6 @@ dependencies = [
|
|||
"filmcraft-color",
|
||||
"filmcraft-edit",
|
||||
"filmcraft-engine",
|
||||
"filmcraft-export",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-gpu",
|
||||
|
|
@ -2340,7 +2209,6 @@ dependencies = [
|
|||
"filmcraft-scopes",
|
||||
"filmcraft-text",
|
||||
"filmcraft-time",
|
||||
"filmcraft-tts",
|
||||
"image",
|
||||
"log",
|
||||
"pollster 0.4.0",
|
||||
|
|
@ -2348,21 +2216,12 @@ dependencies = [
|
|||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 1.1.6+spec-1.1.0",
|
||||
"unicode-bidi",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp8"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
|
|
@ -2371,7 +2230,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -2929,12 +2788,6 @@ dependencies = [
|
|||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
|
|
@ -3297,25 +3150,6 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
|
|
@ -4241,16 +4075,6 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
|
|
@ -5930,15 +5754,6 @@ dependencies = [
|
|||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysctl"
|
||||
version = "0.6.0"
|
||||
|
|
@ -7396,7 +7211,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
|
|
|||
24
Cargo.toml
24
Cargo.toml
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.2"
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
@ -17,26 +17,20 @@ 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" }
|
||||
|
|
@ -49,8 +43,6 @@ filmcraft-interchange = { path = "crates/interchange" }
|
|||
filmcraft-text = { path = "crates/text" }
|
||||
filmcraft-captions = { path = "crates/captions" }
|
||||
filmcraft-speech = { path = "crates/speech" }
|
||||
filmcraft-tts = { path = "crates/tts" }
|
||||
filmcraft-tts-text = { path = "crates/tts-text" }
|
||||
filmcraft-render = { path = "crates/render" }
|
||||
filmcraft-gpu = { path = "crates/gpu" }
|
||||
filmcraft-scopes = { path = "crates/scopes" }
|
||||
|
|
@ -72,8 +64,6 @@ 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"
|
||||
|
|
@ -111,21 +101,12 @@ opt-level = 3
|
|||
[profile.dev.package.filmcraft-dnx]
|
||||
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
|
||||
|
|
@ -136,9 +117,6 @@ 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
|
||||
|
||||
|
|
|
|||
3
NOTICE
3
NOTICE
|
|
@ -15,7 +15,4 @@ list (author, source, license) is in ATTRIBUTION.md.
|
|||
Inter font (assets/fonts/Inter-*.ttf) — OFL-1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-NotoSerif.txt.
|
||||
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
|
||||
BIZ UDMincho) — OFL-1.1. Their licence texts ship with the packages as OFL-<family>.txt.
|
||||
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md.
|
||||
|
|
|
|||
136
README.md
136
README.md
|
|
@ -59,7 +59,6 @@
|
|||
<a href="#interchange">Interchange</a> ·
|
||||
<a href="#built-for-agents">Agents</a> ·
|
||||
<a href="#get-started">Get started</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#documentation">Docs</a> ·
|
||||
<a href="#status">Status</a>
|
||||
</p>
|
||||
|
|
@ -83,7 +82,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
|||
- **Three-point editing.** Mark In and Out in the Source monitor, then insert (`,`) or overwrite (`.`) onto the patched tracks. Lift (`;`) and extract (`'`) take ranges back out.
|
||||
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
|
||||
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
|
||||
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.
|
||||
|
||||
|
|
@ -137,7 +136,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
|||
|
||||
- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
|
||||
- **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes.
|
||||
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
|
||||
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter, and each animated parameter opens into **value and velocity graphs** with draggable influence handles.
|
||||
- **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -175,7 +174,6 @@ 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 |
|
||||
|
|
@ -184,10 +182,9 @@ 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 |
|
||||
| **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 |
|
||||
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
|
||||
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: 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)).
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: HEVC and AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), with Linux and Windows to follow ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
|
||||
<br>
|
||||
|
||||
|
|
@ -199,7 +196,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
|
|||
|
||||
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
|
||||
|
||||
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes in ffmpeg without a single warning, with strict error detection on.
|
||||
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes cleanly in ffmpeg with error concealment switched off.
|
||||
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
|
||||
- **Background jobs** with progress and cancel, so you keep editing while it renders.
|
||||
- **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back.
|
||||
|
|
@ -223,7 +220,7 @@ Import merges the document's bins, media and sequences into your project as one
|
|||
|
||||
## Built for agents
|
||||
|
||||
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
|
||||
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are about 135 engine commands so far, with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
|
||||
|
||||
```jsonc
|
||||
// over the control channel (JSON lines on TCP) or as MCP tool calls
|
||||
|
|
@ -241,7 +238,7 @@ The trailer and the grades in these screenshots were built exactly this way, by
|
|||
## Everywhere
|
||||
|
||||
- **Native** on macOS, Windows and Linux, with a native macOS menu bar.
|
||||
- **The web:** the same engine and UI run in the browser via WebAssembly (`apps/filmcraft-web`, see [docs/web.md](docs/web.md)); every release ships it as `filmcraft-web-<version>.zip`.
|
||||
- **The web:** every crate up to the engine compiles to `wasm32`; the browser front end is next.
|
||||
- **Swappable UI.** The interface is one crate (`ui-egui`) over the engine, so a different front end can replace it without touching editing logic.
|
||||
|
||||
<br>
|
||||
|
|
@ -255,13 +252,6 @@ cargo run --release -p filmcraft-cli -- commands # list every engine c
|
|||
cargo run --release -p filmcraft-cli -- mcp # MCP server (headless)
|
||||
```
|
||||
|
||||
Japanese text in the interface and in titles comes from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (release builds always include it; without it FilmCraft uses its own and the system's fonts):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p filmcraft
|
||||
```
|
||||
|
||||
The control protocol is documented in [docs/control-protocol.md](docs/control-protocol.md). Stuck, or want to show what you made? Ask in [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Documentation
|
||||
|
|
@ -276,25 +266,22 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr
|
|||
| [docs/control-protocol.md](docs/control-protocol.md) | Control-channel and MCP reference |
|
||||
| [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery |
|
||||
| [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats |
|
||||
| [ROADMAP.md](ROADMAP.md) | Stage, parity numbers, languages, what's next and the progress log |
|
||||
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment against Premiere Pro, the ranked list of shortfalls, milestones and current focus |
|
||||
| [docs/codec-parity.md](docs/codec-parity.md) · [docs/file-format-parity.md](docs/file-format-parity.md) · [docs/hardware-parity.md](docs/hardware-parity.md) · [docs/ui-parity.md](docs/ui-parity.md) · [docs/localization-parity.md](docs/localization-parity.md) | Parity checklists by dimension |
|
||||
| [ROADMAP.md](ROADMAP.md) | Honest assessment, what's missing, milestones and estimates |
|
||||
|
||||
## Status
|
||||
|
||||
FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today.
|
||||
|
||||
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10 against Premiere Pro 26.5.2):
|
||||
We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)):
|
||||
|
||||
- **Feature breadth: ~86%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
|
||||
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
|
||||
- **Feature checklist: ~87%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft.
|
||||
- **Ready for real work: ~50–60%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
|
||||
|
||||
The biggest gaps today:
|
||||
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
|
||||
- **Speed on big footage.** Hardware decoding works on macOS; Windows and Linux have no hardware path yet. Blend modes and the most common effects run on the GPU, but Lumetri, keys, export and encoding still run on the CPU ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
|
||||
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
|
||||
- **Delivery codecs.** H.264 is our only delivery-codec export; no HEVC or AV1 export yet.
|
||||
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
|
||||
- **AI features.** Few so far; speech to text is optional and off by default.
|
||||
|
||||
|
|
@ -306,7 +293,7 @@ A layered Cargo workspace:
|
|||
|
||||
| Layer | Crates |
|
||||
|---|---|
|
||||
| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `avi`, `bitstream` |
|
||||
| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `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` |
|
||||
|
|
@ -316,91 +303,6 @@ A layered Cargo workspace:
|
|||
|
||||
Nothing below the front ends depends on a UI toolkit or OS API. `cargo xtask ci` checks formatting, lints, tests, the layering rules, asset attribution and the wasm build.
|
||||
|
||||
## Downloads
|
||||
|
||||
**New to FilmCraft?** Download it from the [FilmCraft page on getartcraft.com](https://getartcraft.com/apps/filmcraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/filmcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/filmcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `filmcraft-<ver>-windows-x64.msi` | `filmcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `filmcraft-<ver>-windows-arm64.msi` | `filmcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `filmcraft-<ver>-windows-x86.msi` | `filmcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only Vulkan and DirectX 12 by
|
||||
default, not OpenGL. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
|
||||
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
|
||||
PowerShell, from the folder containing the executable:
|
||||
|
||||
```powershell
|
||||
$env:WGPU_BACKEND = "vulkan"
|
||||
& .\filmcraft.exe
|
||||
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
|
||||
```
|
||||
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
A PC whose GPUs were installed from different driver packages of one vendor (a laptop with an
|
||||
integrated and a discrete AMD GPU, for example) can list one GPU twice, once through each installed
|
||||
version of the Vulkan driver, and a window drawn through the stale one crashes inside the driver
|
||||
(`amdvlk64.dll`, exit code `0xc0000005`). FilmCraft draws with the newer one and says so in
|
||||
`%APPDATA%\FilmCraft\Logs\filmcraft.log`; installing one driver package for every GPU removes the
|
||||
duplicate. With one driver per GPU the adapter is the one wgpu picks, as before.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `filmcraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `filmcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `filmcraft-<ver>-linux-x86_64.AppImage` | `filmcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `filmcraft-<ver>-linux-x86_64.flatpak` | `filmcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `filmcraft-<ver>-linux-x86_64.deb` | `filmcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `filmcraft-<ver>-linux-x86_64.rpm` | `filmcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `filmcraft-<ver>-linux-x86_64.tar.gz` | `filmcraft-<ver>-linux-aarch64.tar.gz` | Unpack and run `./install.sh` |
|
||||
|
||||
RISC-V (riscv64): `filmcraft-<ver>-linux-riscv64.tar.gz` (tarball only; needs glibc 2.39+, e.g. Ubuntu 24.04
|
||||
or Debian 13).
|
||||
|
||||
The tarball installer places FilmCraft, its command-line tool and desktop integration in
|
||||
`~/.local`, without administrator permissions. For all users, run
|
||||
`sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation.
|
||||
|
||||
**Gentoo (community-maintained):** the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay)
|
||||
packages the Linux release as `media-video/filmcraft-bin`. It is maintained by the community, not by the
|
||||
FilmCraft team, so report packaging problems to the overlay:
|
||||
|
||||
```sh
|
||||
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
|
||||
emaint sync -r snakebyte
|
||||
echo 'media-video/filmcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/filmcraft
|
||||
emerge --ask media-video/filmcraft-bin
|
||||
```
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `filmcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `filmcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
FilmCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
|
|
@ -413,7 +315,7 @@ stand on its own.
|
|||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | **Video editing, color and sound · you are here** | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
|
|
@ -449,9 +351,7 @@ FilmCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APAC
|
|||
Copyright (c) 2026 ArtCraft Team and the FilmCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds made with
|
||||
[craft-fonts](https://github.com/storytold/craft-fonts) (all official releases) also embed its fonts
|
||||
(OFL-1.1), listed in its [ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
**Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
|
|
@ -470,7 +370,3 @@ Forks and modified versions must remove them.
|
|||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Ffilmcraft&type=date&legend=top-left)
|
||||
|
|
|
|||
258
ROADMAP.md
258
ROADMAP.md
|
|
@ -1,122 +1,163 @@
|
|||
# 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.
|
||||
Progress toward parity with Adobe Premiere Pro, with estimates. Updated as milestones land.
|
||||
|
||||
**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
|
||||
**Last updated:** 2026-10-05 · **Feature checklist:** ~87% · **Ready for real work:** ~50–60% · **Code:** 35 crates, 1605+ tests
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
|
||||
Two numbers, because they answer different questions. The **feature checklist** counts whether
|
||||
Premiere's menu items, effects, panels and formats exist in FilmCraft. **Ready for real work** is
|
||||
our honest estimate of how close FilmCraft is to replacing Premiere for an editor on real
|
||||
projects: depth, correctness, robustness, speed and the pro ecosystem. The gap between the two
|
||||
is the work that matters most now. Agents: read [Where we are lacking](#where-we-are-lacking)
|
||||
before choosing work.
|
||||
|
||||
Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
|
||||
[docs/gaps.md](docs/gaps.md) (the ranked work list: **agents, read it before choosing work**) and
|
||||
[docs/roadmap.md](docs/roadmap.md) (milestones and current focus).
|
||||
## Honest assessment (2026-10-05)
|
||||
|
||||
## Headline numbers
|
||||
### How the checklist number is made, and what it doesn't tell you
|
||||
|
||||
| | Value | How |
|
||||
- **Partly measured.** The menu figure comes from diffing a dump of Premiere 26.5's menus against
|
||||
`filmcraft-cli commands`. The effects, transitions and audio-effects counts are checked by unit
|
||||
tests against Premiere's Effects panel tree (`premiere_26_video_effects_catalogue`,
|
||||
`premiere_audio_set_is_complete_and_foldered`, `premiere_26_transition_tree`).
|
||||
- **Partly agent-reported.** The other areas' percentages are written by the agents doing the work.
|
||||
No automated parity check runs in CI (PhotoCraft has `cargo xtask parity`; we don't yet), so
|
||||
nothing stops a number drifting upward.
|
||||
- **Presence, not quality.** An item counts once it exists. An effect described as an
|
||||
"approximation" counts the same as an exact one. Nothing compares our rendering with
|
||||
Premiere's.
|
||||
- **Weights understate what users hit first.** Performance carries 5%. Plugins and AI features
|
||||
aren't in the scorecard at all.
|
||||
|
||||
### By dimension
|
||||
|
||||
| Dimension | Checklist says | Honest estimate | Evidence |
|
||||
|---|---|---|---|
|
||||
| **Feature breadth** (menus, commands, panels) | ~86–95% | **~85%** | Menu diff against Premiere 26.5; effect, transition and audio-effect catalogues complete by test. The long tail is mostly there. |
|
||||
| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. |
|
||||
| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. |
|
||||
| **Export** | ~92% | **~70%** | Delivery: H.264 only (ProRes, DNxHR and image sequences cover mastering). No HEVC or AV1 export. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS only (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects, export and encoding are CPU. Linux and Windows have no hardware path. 8K and AV1 aren't real-time. The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. |
|
||||
| **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. |
|
||||
| **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. |
|
||||
| **Platforms** | not tracked | **macOS solid; Windows and Linux thin** | Development and testing happen on macOS. Windows and Linux are built for releases but barely exercised at runtime. The web build is a compile target; the browser app shell isn't finished. |
|
||||
| **UI fidelity** | ~85% | **~80%** | Agents compare with Premiere reference screenshots; this hasn't been checked independently. |
|
||||
|
||||
### Where we are lacking
|
||||
|
||||
In priority order. Agents should prefer this work over adding more checklist items.
|
||||
|
||||
1. **Hardware acceleration** (#30), the most visible gap to users:
|
||||
- hardware decode on Linux (VA-API) and Windows (Media Foundation);
|
||||
- zero-copy decoded frames into wgpu;
|
||||
- the remaining effects on the GPU: Lumetri, keys, Vignette, Video Limiter, masks (31 common effects are done);
|
||||
- GPU export, then hardware encode (H.264 / HEVC).
|
||||
2. **A measured parity number.** Add `cargo xtask parity`, run in CI. It should cover menus,
|
||||
commands, effects, transitions, panels, preferences and formats against the Premiere snapshot in
|
||||
`plan/premiere/`. It should report presence and fidelity separately (exact, approximate, stub).
|
||||
3. **Real-world media corpus.** Phone, camera and screen recordings, variable frame rate, damaged
|
||||
files, long GOPs, odd containers. Each gets an import → edit → export round-trip test with an
|
||||
ffprobe / ffmpeg oracle.
|
||||
4. **Workflow acceptance tests.** Scripted MCP sessions that do real jobs end to end: assemble and
|
||||
trim a scene, colour it, mix and loudness-normalise it, caption it, deliver it, round-trip
|
||||
through XML / AAF.
|
||||
5. **Windows and Linux at runtime.** CI that runs the test suite and a headless playback/export
|
||||
smoke test on both, not just release builds. Fix the Linux audio path (#23).
|
||||
6. **Delivery codecs.** HEVC and AV1 export: hardware encoders first, and `rav1e` (BSD-2) for AV1.
|
||||
7. **Plugin hosting** (VST3 / Audio Units, then OpenFX). Needs FFI, so it falls under the isolated
|
||||
`unsafe` crate rule (AGENTS.md §0.3). **Owner decision needed** before starting.
|
||||
8. **AI features** with openly licensed local models: transcription on by default, text-based
|
||||
editing, speech enhancement, reframing.
|
||||
9. **The web app shell**: file access, WebCodecs, audio.
|
||||
|
||||
### Where we're going
|
||||
|
||||
**Next:** close items 1–5. Hardware acceleration fixes the gap users notice first (CPU-bound 4K playback and export). The
|
||||
parity tool, media corpus and workflow tests make the progress number trustworthy. Windows and
|
||||
Linux CI makes "runs everywhere" true.
|
||||
|
||||
**After that:** delivery codecs, plugin hosting (pending the owner's decision) and AI features,
|
||||
measured with the same tools.
|
||||
|
||||
**Goal:** "ready for real work" at 85%+, measured rather than estimated, on all three desktop
|
||||
platforms.
|
||||
|
||||
## Feature checklist scorecard
|
||||
|
||||
Measured against Premiere Pro 26.5 on this machine. Menu items: the native menu bar dump, minus
|
||||
Adobe-cloud-only items (Team Projects, Productions, Firefly, Stock, Dynamic Link, account/help pages),
|
||||
matched against `filmcraft-cli commands` and the UI command table. Effects: the Effects panel tree.
|
||||
|
||||
| Area | Weight | Measured | Coverage |
|
||||
|---|---|---|---|
|
||||
| Editing, timeline, trimming, multicam | 20% | core edit algebra, trim modes, sync, multicam, menu long tail, Scene Edit Detection, Automate to Sequence, Premiere default keyboard (143 new commands; 39 cloud/absent-panel keys skipped) | ~86% |
|
||||
| Menus / commands | (cross-check) | ~325 of 344 in-scope menu items | ~95% |
|
||||
| Effects and transitions | 12% | video effects 93/93 (+Legacy, Obsolete), audio effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3; some approximations (Warp Stabilizer 2-D, Morph Cut, Auto Reframe) | ~92% |
|
||||
| Media I/O and codecs | 12% | H.264, HEVC, VP9, AV1, ProRes, DNx, MJPEG, MPEG-2/MPEG-1 (4:2:2 incl. IMX/XDCAM), Opus, AAC, AC-3, MP2; MP4/MOV/MKV/WebM, MXF (incl. MPEG-2), MPEG TS/PS (AVCHD, broadcast, DVD), Ogg, image sequences, BWF; no E-AC-3, camera raw, HW decode | ~90% |
|
||||
| Panels and UI fidelity | 12% | all main panels incl. Metadata, Timecode, Events, Progress, Reference Monitor, full Lumetri Scopes, audio effect editors; Project panel List/Icon/Freeform with view presets and hover scrub; Media Browser with Favorites/navigation/ingest | ~85% |
|
||||
| Audio | 10% | mixer, automation (track + clip), Essential Sound, meters, all 53 effects, 5.1 tracks/buses/panner/export, voice-over record, Remix | ~88% |
|
||||
| Colour | 8% | Lumetri complete, LUTs, colour management, HDR, all Lumetri Scopes (+ `scopes.read` for agents) | ~88% |
|
||||
| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% |
|
||||
| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% |
|
||||
| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; no hardware path on Windows/Linux (see the honest assessment: ~35–40% against Premiere) | ~62% |
|
||||
| **Weighted total** | | | **~87%** |
|
||||
|
||||
## Estimate to parity
|
||||
|
||||
This estimate is for the **feature checklist**. Closing the honest-assessment gaps (above) is the
|
||||
"Robust on real-world material" row, plus plugins and AI features, which it doesn't include.
|
||||
|
||||
Measured throughput in the last work block (2026-10-01 night → 10-02): five Opus 5.5 agents in
|
||||
parallel for ~4.5 wall-clock hours (~18 agent-hours including integration) moved parity by ~4 points
|
||||
(≈4–5 agent-hours per point), on a machine that was heavily overloaded (load 150–300 on 14 cores) and
|
||||
once ran out of disk. The remaining points are a long tail (each effect, dialog and preference page is
|
||||
small, but there are many), so the estimate applies a 1.3–1.5× tail factor.
|
||||
|
||||
| | Opus 5.5 agent-hours | Wall-clock (4–5 parallel agents + integrator) |
|
||||
|---|---|---|
|
||||
| **Feature breadth** | **~86%** | Does each Premiere feature exist? Menus 92% (measured: label diff against Premiere 26.5.2's menu dump), effects / transitions / audio effects 100% (measured by tests), other areas estimated |
|
||||
| **Ready for real work** | **~56%** (51–61%) | How close FilmCraft is to replacing Premiere for an editor on real projects: depth, correctness, file compatibility, speed, stability, ecosystem. Estimated depth per area, combined by a [written formula](docs/target-app-parity.md#three-readiness-numbers) |
|
||||
| **Mainstream practitioner** | **~55%** | A typical professional editor's weekly work only, discounted ×0.90 interaction, ×0.92 stability, ×0.88 file exchange with Premiere users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| **Essentials user** | **~60%** | Import, cut, title, music, export at default settings, discounted ×0.88 launch / stability, ×0.90 discoverability, ×0.95 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| **To beta** | **~400–700 Opus 5.5 agent-hours** | Estimated, calibrated from this repo's PR history |
|
||||
| **To full parity** | **~1,550–2,750 agent-hours** | ~70% parallelizes; plugins, AI models, native-speaker review and some hardware need a human |
|
||||
| Code (measured) | 45 crates, ~343k lines of Rust, 2,520 tests, 268 merged PRs, 69 authors | `git`, `gh`, `grep` |
|
||||
| Feature parity by checklist (~19 points left) | ~90–125 | **~20–32 h** |
|
||||
| Robust on real-world material (codec edge cases, 4K/8K performance, pro workflows) | +120–200 | **+30–50 h** |
|
||||
| **Total to "100% and better"** | **~210–325** | **~50–82 h (≈2–3.5 days, 24/7)** |
|
||||
|
||||
Two numbers, because they answer different questions. The gap between them is the work that
|
||||
matters most now: breadth is nearly done, depth is not.
|
||||
The 2026-10-02 block moved parity ~62% → ~72% with six agents in ~6 wall-clock hours, despite the
|
||||
disk filling twice; the remaining work is mostly panels (scopes, Metadata, Media Browser), export
|
||||
presets/queue, codecs (MXF, image sequences, hardware decode), performance and graphics templates.
|
||||
|
||||
### Readiness by audience
|
||||
Limits on speed: CPU and disk on one machine (more than ~5 agents slows everyone down: each worktree
|
||||
build is 6–7 GB and a full `cargo xtask ci` takes 20–60 min under load), and a single integrator
|
||||
merging, resolving conflicts (e.g. two agents bumping the project schema) and re-running CI. With one
|
||||
agent and no parallelism, multiply wall-clock by ~3–4.
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~56% | ~1,450–2,600 (~70% parallelizes) | Plugin hosting, AI models, localization incl. RTL / Devanagari, camera RAW and delivery codecs, `.prproj`, hardware acceleration, UI fidelity |
|
||||
| Mainstream practitioner | ~55% | ~500–850 (~65% parallelizes) | `.prproj` import (60–120), UI fidelity (120–200), stability and PR CI (40–80), 10-bit / 4:2:2 camera decode (30–50), playback / hardware (60–100), import and export gaps (60–100) |
|
||||
| Essentials user | ~60% | ~180–350 (~60% parallelizes) | Launch crashes on Windows / Intel / older macOS (40–80), discoverability and timeline friction (60–110), playback lag (30–60), common import gaps (20–40), audio devices (15–30) |
|
||||
Not reachable clean-room and locally: **Generative Extend** (needs a large video-generation model).
|
||||
**Enhance Speech** and **Auto Reframe** are feasible only with openly licensed models we can ship.
|
||||
|
||||
Method and calibration: [docs/target-app-parity.md](docs/target-app-parity.md#three-readiness-numbers).
|
||||
## Milestones
|
||||
|
||||
## By dimension
|
||||
Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining agent-hours.
|
||||
|
||||
| Dimension | Ready | Hours | Doc |
|
||||
|---|---|---|---|
|
||||
| Features (editing, effects, colour, audio, graphics, export settings) | ~66% | 350–620 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) |
|
||||
| UI / UX fidelity | ~60% | 120–200 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats (containers, `.prproj`, interchange, captions, stills) | ~60% | 140–240 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Codecs | ~60% | 150–250 | [codec-parity.md](docs/codec-parity.md) |
|
||||
| Hardware acceleration | ~45% | 120–200 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | ~35% | 150–230 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~55% | 60–120 | [performance.md](docs/performance.md) |
|
||||
| Stability | ~45% | 60–120 | [gaps.md G4](docs/gaps.md#g4-crashes-at-start-up-and-no-test-ci-on-pull-requests) |
|
||||
| Platforms (macOS, Windows, Linux, FreeBSD, web) | ~60% | 50–90 | [gaps.md G9](docs/gaps.md#g9-windows-and-linux-at-run-time) |
|
||||
| Plugins and ecosystem | ~5% | 200–400 | [gaps.md G11](docs/gaps.md#g11-no-plugin-hosting) |
|
||||
| AI features | ~25% | 150–300 | [gaps.md G12](docs/gaps.md#g12-ai-features) |
|
||||
| # | Milestone | Status | Done | Remaining | Est. |
|
||||
|---|---|---|---|---|---|
|
||||
| M0 | Skeleton + visual shell | ✅ | Workspace, 20 crates, dock/workspaces, Premiere 26 look, native menus, control channel, MCP, xtask gates (layers, wasm) | — | — |
|
||||
| M1 | Media I/O | ✅ | MP4/MOV demux+mux, WAV, stills, MJPEG, symphonia audio (MP3/FLAC/ALAC/Vorbis), GOP seek + frame cache, import | Media Browser polish | 2 |
|
||||
| M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — |
|
||||
| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); M3.12 keyboard parity: Premiere's default keyboard (~115 keyboard-only commands: edit-point navigation on targeted/any track, select clip at playhead/next/previous, extend edit to playhead, nudge/slip/slide selection, target and source-patch toggles, clip volume ±1 dB/many, frame maximize/full screen/panel cycling, monitor zoom, track heights, Project and Text panel keyboard navigation, text size/leading/alignment, Export Frame, poster frames; see docs/keyboard.md); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), 39 Premiere default shortcuts skipped with reasons in docs/keyboard.md (Productions, AI/cloud tools, work area bar, Production/Search panels) | 6–10 |
|
||||
| M4 | Playback | 🟡 | Audio-clock master, prefetch with cancellation, J/K/L, correct dropped-frame stats, playback resolution, render bar + content-hashed render previews, App Nap opt-out; 1080p H.264 and 3 stacked 1080p streams play with 0 dropped frames | 4K under load, 8K, frame-threaded AV1 decode, reduced-resolution decode for multicam grids | 6–10 |
|
||||
| M5 | Effects, keyframes, GPU | 🟡 | All 93 Premiere 26 video effects and 84 transitions (CPU), blend modes and 31 common effects on the GPU (#32, GPU2), keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | Effects on the GPU (in progress, #30), GPU masks in the live path, GPU export, exact Warp Stabilizer (3-D) and Morph Cut | 20–30 |
|
||||
| M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 or QuickTime + own AAC; ProRes, DNxHR, MJPEG, PNG/TIFF/BMP sequences (Premiere-style numbering), GIF, WAV, AIFF; background jobs; M6.5: Export mode parity (frame size / rate / scaling / pixel aspect, profile / level, CBR / VBR 1- and 2-pass, keyframe distance, audio codec / rate / channels / bitrate / sample size, multiplexer, burn-in or sidecar captions, image / name / timecode overlays, video limiter, loudness normalization with true-peak limiter, metadata, ranges, estimated size, summary), 24 built-in presets + user presets with favourites and import/export (Preset Manager), export queue (reorder, cancel, retry, several sequences / ranges), Quick Export; `export.*` commands and `filmcraft-cli export --preset` | Interlaced encoding, 5.1 audio, smart render, publishing destinations | 6–8 |
|
||||
| M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 |
|
||||
| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 |
|
||||
| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4), DNxHD/DNxHR decoder (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2/4:4:4, interlaced, alpha; within IDCT precision of ffmpeg on 22 fixtures) + DNxHR LB/SQ/HQ/HQX/444 encoder and MOV `AVdh` export, AV1 decoder (Main profile, 8/10-bit, all intra/inter tools, loop filters, superres, film grain, intra BC, spatial layers; bit-exact vs libdav1d on 19 SVT fixtures + 22 libaom vectors; MP4 `av01` / WebM `V_AV1` import), MXF import (OP1a/OP-Atom; AVC with POC-ordered B pictures bit-exact, DNxHD/DNxHR, ProRes, PCM/AES3; MPEG-2 reported unsupported; index-table seeking, material package timecode), Ogg Opus/Vorbis (granule seeking, pre-skip, end trimming), image sequences, Broadcast WAV timecode | VP9 frame threading, AV1 threading/SIMD (~4 fps 1080p today), AV1 High/Professional profiles, MPEG-2 decoder, hardware decode | 12–20 |
|
||||
| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute; graphics templates (own `.fcgt` format, 8 original built-ins, Browse tab with engine-rendered thumbnails, export / install / apply / editable properties; never reads .mogrt), rolls/crawls with ease and pre/postroll, responsive pins and intro/outro, per-character styles, Upgrade Caption to Graphic, source graphics, Replace Fonts; MCC (608+708), EBU STL, TTML/IMSC1, DFXP (frame-exact, property-tested) | 608/708 embedding in video streams, speech-to-text | 12–18 |
|
||||
| M11 | Interchange & project management | ✅ | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO; offline media (own slate) + Link Media (fingerprint-checked relink, folder remap, search, Align Timecode, Make Offline); proxies (ProRes Proxy/LT, H.264 ¼/½ background jobs, attach/detach/reconnect, monitor toggle, export full-res) + ingest (copy/transcode/proxies); Project Manager (collect, consolidate + transcode with handles, size estimate) | Rename media to clip names, image-sequence conversion, Media Browser-driven relink, smart (cross-drive) path tracking | 1–2 |
|
||||
| M12–M16 | Multicam, web (WASM), platform, long tail | 🟡 | Multicam: audio sync (GCC-PHAT, sub-sample), Merge Clips, multi-camera source sequences, Multi-Camera view with live switching (1–9); L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), scene detection, auto reframe, ~800 remaining commands and dialogs, performance hardening | 35–55 |
|
||||
|
||||
## Features
|
||||
## Running now
|
||||
|
||||
| Area | Breadth | Ready | Hours |
|
||||
|---|---|---|---|
|
||||
| Editing, timeline, trimming, multicam | ~90% | ~70% | 80–140 |
|
||||
| Effects and transitions | 100% (measured) | ~60% | 80–140 |
|
||||
| Media import and codecs | ~75% | ~60% | 150–250 |
|
||||
| Audio | ~88% | ~65% | 50–90 |
|
||||
| Panels and UI fidelity | ~88% | ~60% | 120–200 |
|
||||
| Colour | ~90% | ~70% | 40–70 |
|
||||
| Graphics, titles, captions | ~85% | ~65% | 50–90 |
|
||||
| Export and delivery | ~80% | ~65% | 60–110 |
|
||||
| Project files and interchange | ~70% | ~45% | 90–160 |
|
||||
| Preferences and project management | ~80% | ~65% | 20–40 |
|
||||
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 31 common effects on the GPU (GPU2). Next: VA-API (Linux) and Media Foundation (Windows) decode, zero-copy upload, Lumetri on the GPU.
|
||||
- **Next:** [Where we are lacking](#where-we-are-lacking) items 1–5. GPU export waits for the export-crate work (#19 and the never-crash follow-up) to land.
|
||||
|
||||
Evidence for every row: [docs/target-app-parity.md](docs/target-app-parity.md#by-feature-area).
|
||||
|
||||
## Languages
|
||||
|
||||
Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings):
|
||||
|
||||
| Language | Status | Strings |
|
||||
|---|---|---|
|
||||
| English | full | 100% |
|
||||
| Simplified Chinese | partial (whole interface; engine errors English) | 99.6% |
|
||||
| Spanish | partial (whole interface) | 99.9% |
|
||||
| Hindi | none (no Devanagari shaping in the interface) | 0% |
|
||||
| Arabic | none (no RTL interface) | 0% |
|
||||
| French | partial (whole interface; native review welcome) | ~100% |
|
||||
| Portuguese (Brazil) | partial (whole interface; native review welcome) | ~100% |
|
||||
| Indonesian | partial (whole interface; native review welcome) | ~100% |
|
||||
| Italian | partial (whole interface; native review welcome) | ~100% |
|
||||
| Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% |
|
||||
| Japanese | partial (whole interface) | 99.7% |
|
||||
| German | menus only (native-reviewed) | 10.6% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md).
|
||||
|
||||
1. **No launch crashes, and CI on pull requests** (macOS 12, Intel UHD, Windows 11 reports; no workflow runs the tests today). 40–80 h.
|
||||
2. **Open Premiere's `.prproj`**, the beta blocker. 60–120 h.
|
||||
3. **10-bit / 4:2:2 camera media without hardware** (H.264 High 10 / 4:2:2, HEVC 4:2:2; #626). 30–50 h.
|
||||
4. **Measure fidelity against Premiere** and add `cargo xtask parity`. 50–90 h.
|
||||
5. **Real-world media corpus and workflow acceptance tests.** 40–70 h.
|
||||
6. **Hardware acceleration, continued** (#30): zero-copy upload, more effects on the GPU, Quick Sync / AMD encoders, VideoToolbox Main 10. 120–200 h.
|
||||
7. **UI fidelity:** Program monitor transform handles, Effect Controls, docking. 120–200 h.
|
||||
8. **Plugin hosting** (VST3 / AU, then OpenFX). Owner decision needed. 200–400 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)
|
||||
- **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`).
|
||||
- **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)).
|
||||
- **2026-10-10:** ~75 community PRs merged: GPU export rendering (Auto) with Vignette, Video Limiter and Lumetri basic / creative / vignette on the GPU (#421); Linux NVDEC H.264 / HEVC decode (#549); text-to-speech narrations with Kokoro-82M (#278); Japanese for the whole interface (#449) and Ukrainian menus (#407); ALSA default-device fallback (#254, fixes #23); drop import into the timeline (#517); system appearance (#505); timecode scrub editing (#576); NVENC RGBA input (#486); marker CSV export (#454); many fixes (#526–#575).
|
||||
- **2026-10-09:** release 0.5.0. NVENC H.265 export on Windows, Main and Main 10 HDR (#328, #360); NVENC H.264 export (#386); VA-API H.264 / HEVC decode (#358); Source monitor playback (#423); multi-stream container audio, schema v13 (#361); New Sequence and Sequence Settings dialogs (#269, #355); Spanish for the panels (#238); background decoders and memory stress tests (#385, #381).
|
||||
- **2026-10-08 (Source range dragging):** implicit full-clip In/Out, video-only/audio-only/linked both controls, picture/waveform drag gestures and one-command undoable timeline placement. Shared Source/Program draggable frame-bounded handles and range translation, cancellation, Source marker/navigation routing and marked-range playback/looping.
|
||||
- **2026-10-08 (Source playback):** normal forward playback for video/audio media and subclips in the Source monitor, an independent Source clock, Play/Pause and focused Space, frame stepping, device-clock audio with wall-clock fallback, seeking, and end-of-clip stopping. Windows desktop review verified a real video file and standalone WAV, including In/Out marking. Reverse/shuttle Source playback and source sequences remain unsupported; no parity percentage changed.
|
||||
- **2026-10-08 (night):** HDR H.265 (HEVC Main 10, PQ and HLG, BT.2020, limited range) export on Windows through NVENC (#30): an HDR sequence now exports as real 10-bit HDR (`rgbf_to_yuv420_10` into P010 input buffers; VUI BT.2020 + PQ / HLG; the HDR10 mastering-display and content-light messages as SEI on every IDR for PQ, through the generic SEI payload array because NVENC 12.1 has no mastering-display field; `colr` / `mdcv` / `clli` in the sample entry), where the GPU reports 10-bit HEVC (new `register_hdr_probe` / `hdr_available` in the export crate; elsewhere, and on macOS, HDR sequences still tone-map to 8-bit SDR HEVC, and `settings.sdr` forces that). ffprobe reads `hevc` / `Main 10` / `hvc1` / `yuv420p10le` / `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv` with the mastering-display and content-light side data for PQ; ffmpeg's decode equals ours code for code; the in-process round trip is at 71.1 dB luma PSNR on the 10-bit scale with 803 distinct levels on a ramp and the peak code 940 intact (RTX 5060). 8-bit SDR H.265 is unchanged.
|
||||
- **2026-10-08:** H.265 (HEVC Main, 8-bit 4:2:0, SDR) export on Windows through NVENC (#30): the NVENC session, ring and NV12 path of the H.264 encoder now serve both codecs (the H.264 export is byte-for-byte unchanged); the format is available where the GPU has an HEVC encoder, choosing it is the opt-in, and `hvcC` is built from the SPS the encoder wrote. ffprobe reads `hevc` / `Main` / `hvc1` / `yuv420p` / BT.709; our own HEVC decoder decodes it at 48.9 dB luma PSNR (RTX 5060). Main 10 and HDR remain.
|
||||
- **2026-10-08 (#285):** `filmcraft-cli` no longer panics with "Broken pipe" when its stdout is closed early (`filmcraft-cli commands | head`): later output is dropped, the rest of the work (script lines, `--save`, `--save-as`) still runs, and the exit status still reports failures. Other stdout write errors are reported and make the exit status 1. No change to the checklist or estimates.
|
||||
## Log
|
||||
|
||||
- **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28).
|
||||
|
||||
|
|
@ -162,15 +203,6 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
|
|||
- **2026-09-30 (late morning):** keyframe value/velocity graphs; xtask gates; H.264 MP4 export; Lumetri curves, wheels, looks, HSL secondary.
|
||||
- **2026-09-30 (early morning):** H.264 decoder, ProRes, AAC; GPU compositor; MCP; Premiere 26 visual fidelity pass.
|
||||
|
||||
## Revision history
|
||||
### Interface localization
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
|
||||
| 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) |
|
||||
| 2026-10-10 | major | Restructured to the craftrules progress-docs shape: stage banner, headline numbers, dimension / feature / language tables, upcoming; re-measured against Premiere 26.5.2 (breadth ~87 → ~86%, ready ~50–60% → ~55%); honest assessment and scorecard moved to docs/target-app-parity.md, "Where we are lacking" to docs/gaps.md, milestones to docs/roadmap.md, interface localization to docs/localization-parity.md |
|
||||
| 2026-10-08 | minor | Log entries for HEVC export, Source playback and range dragging |
|
||||
| 2026-10-05 | major | Honest assessment added: checklist ~87% vs ready for real work ~50–60% |
|
||||
Edit > Language switches English/Japanese and persists in UI state. Commands `app.language.english` and `app.language.japanese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses a Japanese font already installed on the system (none is bundled); without one, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
|
||||
|
|
|
|||
|
|
@ -19,20 +19,13 @@ whisper = ["filmcraft-engine/whisper", "filmcraft-engine/speech-download"]
|
|||
|
||||
[dependencies]
|
||||
filmcraft-engine = { workspace = true }
|
||||
filmcraft-export = { workspace = true }
|
||||
filmcraft-project = { workspace = true }
|
||||
filmcraft-render = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
filmcraft-gpu = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
filmcraft-codecs = { workspace = true }
|
||||
filmcraft-platform = { workspace = true }
|
||||
filmcraft-mxf = { workspace = true }
|
||||
filmcraft-mpegts = { workspace = true }
|
||||
filmcraft-ogg = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
filmcraft-automation = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ const VALUED: &[&str] = &[
|
|||
"--end",
|
||||
"--data-dir",
|
||||
"--settings",
|
||||
"--gpu-rendering",
|
||||
];
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
|
@ -61,17 +60,6 @@ impl Args {
|
|||
self.opts.iter().any(|(n, _)| n == k)
|
||||
}
|
||||
|
||||
/// A version query: `--version` anywhere, or `-V` in the subcommand position, so global
|
||||
/// options may come first (`--demo --version`, `--compact -V`).
|
||||
pub fn wants_version(&self) -> bool {
|
||||
self.flag("--version") || self.pos(0) == Some("-V")
|
||||
}
|
||||
|
||||
/// The first option that is not in `allowed`, if any.
|
||||
pub fn unknown_opt(&self, allowed: &[&str]) -> Option<&str> {
|
||||
self.opts.iter().map(|(n, _)| n.as_str()).find(|n| !allowed.contains(n))
|
||||
}
|
||||
|
||||
/// Command params from positionals `from..`: one JSON object, or `key=value` pairs
|
||||
/// (dotted keys nest: `color.r=1`).
|
||||
pub fn params_from(&self, from: usize) -> Result<Value, String> {
|
||||
|
|
@ -147,37 +135,9 @@ mod tests {
|
|||
assert_eq!(a.params_from(2).unwrap(), json!({"a": {"b": [1]}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_after_global_options() {
|
||||
for s in ["--version", "-V", "--demo --version", "--compact -V", "--data-dir d --version", "export --version"] {
|
||||
assert!(args(s).wants_version(), "{s}");
|
||||
}
|
||||
for s in ["version", "--data-dir -V", "exec x -V", "-- --version"] {
|
||||
assert!(!args(s).wants_version(), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_opt_reports_first_unlisted_option() {
|
||||
let allowed = ["--bridge", "--project"];
|
||||
assert_eq!(args("mcp --automation-read-root X").unknown_opt(&allowed), Some("--automation-read-root"));
|
||||
assert_eq!(args("mcp --bridge=a --typo=1").unknown_opt(&allowed), Some("--typo"));
|
||||
assert_eq!(args("mcp --project p --bridge a").unknown_opt(&allowed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_dash_ends_options() {
|
||||
let a = args("import -- --weird-name.mov a.mov");
|
||||
assert_eq!(a.positionals, ["import", "--weird-name.mov", "a.mov"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_rendering_takes_a_space_separated_value() {
|
||||
let a = args("export --gpu-rendering off out.wav --start 0 --end 0.1");
|
||||
assert_eq!(a.positionals, ["export", "out.wav"]);
|
||||
assert_eq!(a.opt("--gpu-rendering"), Some("off"));
|
||||
let b = args("export out.wav --gpu-rendering auto");
|
||||
assert_eq!(b.positionals, ["export", "out.wav"]);
|
||||
assert_eq!(b.opt("--gpu-rendering"), Some("auto"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,32 +4,6 @@
|
|||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
/// `println!` that doesn't panic when stdout is closed (`filmcraft-cli commands | head`) with
|
||||
/// "failed printing to stdout: Broken pipe (os error 32)". Once stdout has failed, later output is
|
||||
/// dropped and the work goes on: see `stdout_failed`.
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if !$crate::stdout_gone() {
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// `print!` counterpart of `outln!`.
|
||||
macro_rules! out {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if !$crate::stdout_gone() {
|
||||
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
mod args;
|
||||
mod probe;
|
||||
|
||||
|
|
@ -37,28 +11,6 @@ use args::{Args, parse_value};
|
|||
use filmcraft_automation::BridgeClient;
|
||||
use filmcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Set once a write to stdout has failed; later output is dropped.
|
||||
static STDOUT_GONE: AtomicBool = AtomicBool::new(false);
|
||||
/// Set when stdout failed for a reason other than a closed reader: the exit status becomes 1.
|
||||
static STDOUT_ERROR: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn stdout_gone() -> bool {
|
||||
STDOUT_GONE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// stdout went away. The program doesn't stop there: the rest of `exec`/`run`/`import` (later
|
||||
/// script lines, `--save`, `--save-as`) still runs and the exit status still tells how it went, so
|
||||
/// `exec … --save | head -1` keeps the edit. A reader that stopped early (a closed pipe) is not an
|
||||
/// error, as in ripgrep; any other write error is reported and makes the exit status 1.
|
||||
fn stdout_failed(e: std::io::Error) {
|
||||
if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
return;
|
||||
}
|
||||
diag(format_args!("filmcraft-cli: can't write to stdout: {e}"));
|
||||
STDOUT_ERROR.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
const HELP: &str = "\
|
||||
filmcraft-cli: drive FilmCraft from the shell (headless engine, or the live app with --bridge)
|
||||
|
|
@ -75,22 +27,14 @@ SUBCOMMANDS
|
|||
inspect [project|sequence] project tree or active sequence as JSON (default: both)
|
||||
import <file>... import media into the project
|
||||
export <out> [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
|
||||
[--gpu-rendering auto|off]
|
||||
[--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
|
||||
a format (h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed
|
||||
from the extension); --range entire|inOut|workArea, or a custom
|
||||
range in seconds; --settings is ExportSettings JSON merged over the
|
||||
preset; --scale renders at a fraction of the frame size (0.5 =
|
||||
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. 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
|
||||
preset; --queue adds to the export queue and runs it instead
|
||||
export --list-presets [query] list export presets (name, category, format) as JSON
|
||||
render --seconds S --out f.png [--scale 0.5] [--allow-offline]
|
||||
render one Program frame to PNG (offline media: as for export)
|
||||
render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG
|
||||
probe <media> [--image-sequence]
|
||||
media info as JSON, with MXF / Ogg / BWF details; with
|
||||
--image-sequence <media> is the first numbered still of a sequence
|
||||
|
|
@ -116,27 +60,16 @@ VALUES
|
|||
Times: 254016000000 ticks per second; most commands also take seconds=, frame= or timecode=.
|
||||
|
||||
EXIT STATUS
|
||||
0 success · 1 a command failed, or export/render drew offline media without --allow-offline
|
||||
· 2 usage error
|
||||
0 success · 1 a command failed · 2 usage error
|
||||
";
|
||||
|
||||
/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's
|
||||
/// reader is gone, which would turn a usage error, a failure or a finished export into a crash.
|
||||
fn write_diag(w: &mut dyn std::io::Write, msg: std::fmt::Arguments) {
|
||||
let _ = writeln!(w, "{msg}");
|
||||
}
|
||||
|
||||
fn diag(msg: std::fmt::Arguments) {
|
||||
write_diag(&mut std::io::stderr(), msg);
|
||||
}
|
||||
|
||||
fn usage(msg: impl std::fmt::Display) -> ! {
|
||||
diag(format_args!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage."));
|
||||
eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.");
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ! {
|
||||
diag(format_args!("filmcraft-cli: {msg}"));
|
||||
eprintln!("filmcraft-cli: {msg}");
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -172,8 +105,7 @@ impl Backend {
|
|||
async fn exec(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
match self {
|
||||
Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()),
|
||||
// a blocking export runs as an app job polled by the client (#91, #92)
|
||||
Backend::Bridge(b) => b.execute(id, params).await.map_err(|e| e.to_string()),
|
||||
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +129,7 @@ impl Backend {
|
|||
|
||||
fn print(a: &Args, v: &Value) {
|
||||
let s = if a.flag("--compact") { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) };
|
||||
outln!("{}", s.unwrap_or_default());
|
||||
println!("{}", s.unwrap_or_default());
|
||||
}
|
||||
|
||||
/// Export format from the output file's extension.
|
||||
|
|
@ -213,76 +145,23 @@ fn format_for(path: &str) -> Option<&'static str> {
|
|||
"tif" | "tiff" => "tiff",
|
||||
"bmp" => "bmp",
|
||||
"aif" | "aiff" => "aiff",
|
||||
"m4a" | "aac" => "aac",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// OS hardware video decoders (VideoToolbox on macOS, Media Foundation on Windows) in front of our
|
||||
/// own, as in the desktop app. Also what `mcp` and the headless commands decode with.
|
||||
/// The GPU export frame renderer (filmcraft-gpu's off-screen compositor behind filmcraft-export's
|
||||
/// frame-renderer hook): exports with GPU rendering Auto composite on the GPU and fall back to the
|
||||
/// CPU reference renderer wherever it cannot.
|
||||
struct GpuFrameRenderer(filmcraft_gpu::ExportRenderer);
|
||||
|
||||
impl filmcraft_export::FrameRenderer for GpuFrameRenderer {
|
||||
fn render(
|
||||
&mut self,
|
||||
project: &filmcraft_project::Project,
|
||||
seq: filmcraft_project::ItemId,
|
||||
t: filmcraft_time::Tick,
|
||||
opts: filmcraft_render::RenderOptions,
|
||||
sources: &dyn filmcraft_render::SourceProvider,
|
||||
) -> Option<filmcraft_render::Image> {
|
||||
self.0.render(project, seq, t, opts, sources)
|
||||
}
|
||||
}
|
||||
|
||||
fn register_gpu_frame_renderer() {
|
||||
filmcraft_export::register_frame_renderer(|| {
|
||||
filmcraft_gpu::ExportRenderer::new().map(|r| Box::new(GpuFrameRenderer(r)) as Box<dyn filmcraft_export::FrameRenderer>)
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if STDOUT_ERROR.load(Ordering::Relaxed) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn cli() {
|
||||
// `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") {
|
||||
out!("{HELP}");
|
||||
if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "-V")) {
|
||||
println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
// `--version` anywhere and `-V` as the subcommand, also after global options (`--demo --version`, #710).
|
||||
if a.wants_version() {
|
||||
outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
register_hardware_decoders();
|
||||
register_gpu_frame_renderer();
|
||||
// OS hardware video decoders (VideoToolbox on macOS) in front of our own, as in the desktop app.
|
||||
let _ = filmcraft_platform::register();
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
|
||||
match cmd {
|
||||
"help" | "-h" => out!("{HELP}"),
|
||||
"version" => outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
|
||||
"help" | "--help" | "-h" => print!("{HELP}"),
|
||||
"version" => println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
|
||||
"probe" => {
|
||||
let path = a.pos(1).unwrap_or_else(|| usage("probe <media> [--image-sequence]"));
|
||||
match probe::probe(path, a.flag("--image-sequence")) {
|
||||
|
|
@ -300,11 +179,11 @@ async fn cli() {
|
|||
src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(f))).unwrap_or_else(|e| fail(e));
|
||||
}
|
||||
let dt = t0.elapsed().as_secs_f64();
|
||||
outln!("{n} frames in {dt:.2}s → {:.1} fps (sequential, via Mp4Source)", n as f64 / dt);
|
||||
println!("{n} frames in {dt:.2}s → {:.1} fps (sequential, via Mp4Source)", n as f64 / dt);
|
||||
let t1 = std::time::Instant::now();
|
||||
let f = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(n / 2))).unwrap_or_else(|e| fail(e));
|
||||
let (w, h, _) = f.to_linear_f32_decimated(2);
|
||||
outln!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
|
||||
println!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
"commands" | "describe" => {
|
||||
let mut b = Backend::open(&a);
|
||||
|
|
@ -330,7 +209,7 @@ async fn cli() {
|
|||
} else {
|
||||
for c in &list {
|
||||
let s = |k: &str| c[k].as_str().unwrap_or("").to_string();
|
||||
outln!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
|
||||
println!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -357,9 +236,9 @@ async fn cli() {
|
|||
let v: Value = serde_json::from_str(line).unwrap_or_else(|e| usage(format!("line {}: {e}", n + 1)));
|
||||
let id = v["id"].as_str().unwrap_or_else(|| usage(format!("line {}: missing \"id\"", n + 1)));
|
||||
match b.exec(id, v.get("params").cloned().unwrap_or(json!({}))).await {
|
||||
Ok(r) => outln!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})),
|
||||
Ok(r) => println!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})),
|
||||
Err(e) => {
|
||||
outln!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e}));
|
||||
println!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e}));
|
||||
failed += 1;
|
||||
if !a.flag("--keep-going") {
|
||||
std::process::exit(1);
|
||||
|
|
@ -385,7 +264,8 @@ async fn cli() {
|
|||
print(&a, &v);
|
||||
}
|
||||
"import" => {
|
||||
let paths: Vec<String> = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect();
|
||||
let paths: Vec<String> =
|
||||
a.positionals[1..].iter().map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned()).unwrap_or_else(|_| p.clone())).collect();
|
||||
if paths.is_empty() {
|
||||
usage("import <file>...");
|
||||
}
|
||||
|
|
@ -428,16 +308,17 @@ async fn cli() {
|
|||
if let Some(r) = a.opt("--range") {
|
||||
p["range"] = json!(r);
|
||||
}
|
||||
set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end"));
|
||||
if let (Some(s0), Some(s1)) = (a.opt("--start"), a.opt("--end")) {
|
||||
p["range"] = json!("custom");
|
||||
p["startSeconds"] = parse_value(s0);
|
||||
p["endSeconds"] = parse_value(s1);
|
||||
}
|
||||
if let Some(js) = a.opt("--settings") {
|
||||
p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}")));
|
||||
}
|
||||
if a.flag("--no-audio") {
|
||||
p["audio"] = json!(false);
|
||||
}
|
||||
if let Some(v) = a.opt("--gpu-rendering") {
|
||||
p["gpuRendering"] = json!(v); // off | auto (validated by the export command)
|
||||
}
|
||||
let mut b = Backend::open(&a);
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = if a.flag("--queue") {
|
||||
|
|
@ -453,9 +334,7 @@ async fn cli() {
|
|||
match r {
|
||||
Ok(v) => {
|
||||
print(&a, &v);
|
||||
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()));
|
||||
eprintln!("exported {out} in {:.1}s", t0.elapsed().as_secs_f64());
|
||||
}
|
||||
Err(e) => fail(format!("export: {e}")),
|
||||
}
|
||||
|
|
@ -470,20 +349,15 @@ 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, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
|
||||
let Some(img) = s.render_program(scale) else { fail("no sequence") };
|
||||
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<Value> = 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));
|
||||
eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0);
|
||||
}
|
||||
"mcp" => {
|
||||
if let Some(opt) = a.unknown_opt(&["--bridge", "--project", "--demo", "--data-dir"]) {
|
||||
usage(format!("unknown mcp option `{opt}`"));
|
||||
}
|
||||
let server = match a.opt("--bridge") {
|
||||
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| usage(e)),
|
||||
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(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"),
|
||||
|
|
@ -497,185 +371,8 @@ async fn cli() {
|
|||
}
|
||||
}
|
||||
|
||||
/// The files an export wrote, from its result: `path` of a direct export, or each queued item's
|
||||
/// `path` (the format may have changed the extension: `out.mp4` exported as APV is `out.mp4.mov`).
|
||||
/// `requested` when the result names none.
|
||||
fn written_paths(result: &Value, requested: &str) -> Vec<String> {
|
||||
let paths: Vec<String> = match result.get("path").and_then(Value::as_str) {
|
||||
Some(p) => vec![p.to_string()],
|
||||
None => result
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|i| i.get("path").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
};
|
||||
if paths.is_empty() { vec![requested.to_string()] } else { paths }
|
||||
}
|
||||
|
||||
/// The offline items an export result reports (`offlineMedia` of a direct export, or of each
|
||||
/// queued item), once each.
|
||||
fn offline_media(result: &Value) -> Vec<Value> {
|
||||
let lists = std::iter::once(result).chain(result.get("items").and_then(Value::as_array).into_iter().flatten());
|
||||
let mut out: Vec<Value> = 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<filmcraft_engine::media_pool::OfflineMedia>), 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
|
||||
/// instead of the export silently running over the whole sequence.
|
||||
fn set_custom_bounds(p: &mut Value, start: Option<&str>, end: Option<&str>) {
|
||||
if start.is_none() && end.is_none() {
|
||||
return;
|
||||
}
|
||||
p["range"] = json!("custom");
|
||||
if let Some(s0) = start {
|
||||
p["startSeconds"] = parse_value(s0);
|
||||
}
|
||||
if let Some(s1) = end {
|
||||
p["endSeconds"] = parse_value(s1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod format_tests {
|
||||
#[test]
|
||||
fn render_reports_the_real_reason() {
|
||||
let mut s = filmcraft_engine::Session::default();
|
||||
assert!(super::render_at_playhead(&s, 0.5).unwrap_err().contains("sequence"));
|
||||
s.execute("file.newSequence", serde_json::json!({"width": 16, "height": 16})).unwrap();
|
||||
for scale in [0.0, -1.0, f32::NAN] {
|
||||
let e = super::render_at_playhead(&s, scale).unwrap_err();
|
||||
assert!(e.contains("finite and positive"), "{e}");
|
||||
}
|
||||
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().0.w, 8);
|
||||
}
|
||||
|
||||
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
|
||||
/// decoders at start-up.
|
||||
#[test]
|
||||
fn startup_registers_the_hardware_decoders() {
|
||||
let hardware = super::register_hardware_decoders();
|
||||
// always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there
|
||||
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_diagnostic_stream_does_not_panic() {
|
||||
struct Closed;
|
||||
impl std::io::Write for Closed {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
}
|
||||
super::write_diag(&mut Closed, format_args!("exported {}", "a.wav"));
|
||||
let mut ok = Vec::new();
|
||||
super::write_diag(&mut ok, format_args!("hi"));
|
||||
assert_eq!(ok, b"hi\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_message_names_the_written_files() {
|
||||
use serde_json::json;
|
||||
let direct = json!({"job": 1, "path": "out-apv.mp4.mov", "result": {"path": "out-apv.mp4.mov"}});
|
||||
assert_eq!(super::written_paths(&direct, "out-apv.mp4"), ["out-apv.mp4.mov"]);
|
||||
let queued = json!({"added": [1, 2], "items": [{"path": "a.mp4.mov"}, {"path": "b.wav"}]});
|
||||
assert_eq!(super::written_paths(&queued, "a.mp4"), ["a.mp4.mov", "b.wav"]);
|
||||
assert_eq!(super::written_paths(&json!({}), "x.mov"), ["x.mov"]);
|
||||
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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;
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, Some("0.25"), None);
|
||||
assert_eq!(p["range"], "custom");
|
||||
assert_eq!(p["startSeconds"], 0.25);
|
||||
assert!(p.get("endSeconds").is_none());
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, None, Some("0.25"));
|
||||
assert_eq!(p["range"], "custom");
|
||||
assert_eq!(p["endSeconds"], 0.25);
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, None, None);
|
||||
assert!(p.get("range").is_none());
|
||||
}
|
||||
|
||||
/// `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"));
|
||||
|
|
|
|||
|
|
@ -22,35 +22,6 @@ 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]
|
||||
fn version_after_global_options() {
|
||||
let want = format!("filmcraft-cli {}\n", env!("CARGO_PKG_VERSION"));
|
||||
for args in [&["--version"][..], &["-V"], &["version"], &["--demo", "--version"], &["--compact", "-V"], &["--demo", "version"]] {
|
||||
let o = cli(args);
|
||||
assert!(o.status.success(), "{args:?}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert_eq!(String::from_utf8_lossy(&o.stdout), want, "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_help_matches_help_command() {
|
||||
let reference = cli(&["help"]);
|
||||
assert!(reference.status.success());
|
||||
let long_help = cli(&["--help"]);
|
||||
assert!(long_help.status.success(), "--help failed: {}", String::from_utf8_lossy(&long_help.stderr));
|
||||
assert_eq!(long_help.stdout, reference.stdout);
|
||||
assert!(long_help.stderr.is_empty());
|
||||
let short_help = cli(&["-h"]);
|
||||
assert!(short_help.status.success());
|
||||
assert_eq!(short_help.stdout, reference.stdout);
|
||||
let sub_help = cli(&["export", "--help"]);
|
||||
assert!(sub_help.status.success(), "export --help failed: {}", String::from_utf8_lossy(&sub_help.stderr));
|
||||
assert_eq!(sub_help.stdout, reference.stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -73,7 +44,7 @@ fn probe_reports_mpeg_transport_and_program_streams() {
|
|||
assert!(st.success());
|
||||
let v = json_out(&cli(&["probe", out.to_str().unwrap()]));
|
||||
assert!(v["video"]["codec"].as_str().unwrap().starts_with("MPEG-2 Video"), "{v}");
|
||||
assert_eq!(v["audio_streams"][0]["codec"], "MPEG Audio");
|
||||
assert_eq!(v["audio"]["codec"], "MPEG Audio");
|
||||
let streams = v["mpeg"]["streams"].as_array().unwrap();
|
||||
assert_eq!(streams.len(), 2, "{v}");
|
||||
assert_eq!(streams[0]["codec"], "MPEG-2 Video");
|
||||
|
|
@ -208,141 +179,9 @@ fn export_with_presets() {
|
|||
let v = json_out(&cli(&["--demo", "--data-dir", data_s, "export", q.to_str().unwrap(), "--preset", "Tiny WAV", "--queue", "--range", "entire"]));
|
||||
assert_eq!(v["items"][0]["status"], "done", "{v}");
|
||||
assert!(q.exists());
|
||||
// a format that changes the extension: the completion message names the file written (#341)
|
||||
let apv = dir.join("apv.mp4");
|
||||
let o =
|
||||
cli(&["--demo", "--data-dir", data_s, "export", apv.to_str().unwrap(), "--preset", "APV 422-10", "--start", "0", "--end", "0.1", "--scale", "0.125"]);
|
||||
let v = json_out(&o);
|
||||
let written = v["path"].as_str().unwrap().to_string();
|
||||
assert!(written.ends_with("apv.mp4.mov") && std::path::Path::new(&written).exists(), "{v}");
|
||||
let msg = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(msg.contains(&format!("exported {written} in ")), "{msg}");
|
||||
let qa = dir.join("queued-apv.mp4");
|
||||
let o = cli(&[
|
||||
"--demo",
|
||||
"--data-dir",
|
||||
data_s,
|
||||
"export",
|
||||
qa.to_str().unwrap(),
|
||||
"--preset",
|
||||
"APV 422-10",
|
||||
"--queue",
|
||||
"--start",
|
||||
"0",
|
||||
"--end",
|
||||
"0.1",
|
||||
"--scale",
|
||||
"0.125",
|
||||
]);
|
||||
let v = json_out(&o);
|
||||
let written = v["items"][0]["path"].as_str().unwrap().to_string();
|
||||
assert!(written.ends_with("queued-apv.mp4.mov"), "{v}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("exported {written} in ")), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
// unknown preset: exit status 1 and a message naming it
|
||||
let bad = cli(&["--demo", "--data-dir", data_s, "export", dir.join("x.mp4").to_str().unwrap(), "--preset", "No Such Preset"]);
|
||||
assert_eq!(bad.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("No Such Preset"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `filmcraft-cli commands | head -c 10` panicked with "failed printing to stdout: Broken pipe
|
||||
/// (os error 32)" and exit status 101 (#285).
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
use std::io::Read;
|
||||
for args in [&["commands"][..], &["help"], &["--demo", "commands", "--json"]] {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 10];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.is_empty(), "{args:?}: {:?}: {err}", out.status);
|
||||
}
|
||||
}
|
||||
|
||||
/// `run -` with stdout already closed: `run` reads its script to the end before it prints, so its
|
||||
/// first write fails for sure. The rest of the script and the save still happen, and the exit status
|
||||
/// still reports a failing command.
|
||||
fn run_with_closed_stdout(args: &[&str], script: &[u8]) -> Output {
|
||||
let mut child =
|
||||
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
child.stdin.take().unwrap().write_all(script).unwrap();
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_stdout_still_runs_the_script_and_saves() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-cli-pipe-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let proj = dir.join("p.fcproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
let script = b"{\"id\":\"file.newBin\",\"params\":{\"name\":\"Piped One\"}}\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"Piped Two\"}}\n";
|
||||
let out = run_with_closed_stdout(&["--demo", "--save-as", p, "run", "-"], script);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let tree = json_out(&cli(&["--project", p, "inspect", "project"])).to_string();
|
||||
assert!(tree.contains("Piped One") && tree.contains("Piped Two"), "{tree}");
|
||||
|
||||
// a failing command is still a failure, whether or not anyone reads stdout
|
||||
let out = run_with_closed_stdout(&["--demo", "run", "-"], b"{\"id\":\"no.such.command\"}\n");
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let out = run_with_closed_stdout(
|
||||
&["--demo", "--keep-going", "run", "-"],
|
||||
b"{\"id\":\"no.such.command\"}\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"x\"}}\n",
|
||||
);
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,488 +0,0 @@
|
|||
//! 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<ChildStdout>,
|
||||
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<u8> {
|
||||
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<Vec<u8>> {
|
||||
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::<f64>() / 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<u8>], want: usize, what: &str) {
|
||||
let d: Vec<f64> = 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<f32> {
|
||||
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<f64> {
|
||||
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::<f64>() / 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<Value> {
|
||||
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<u8>| f.iter().map(|v| f64::from(*v)).sum::<f64>() / f.len() as f64;
|
||||
let flashes: Vec<usize> = 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<u64> = 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<f64> = (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<u64> {
|
||||
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()
|
||||
}
|
||||
|
|
@ -170,11 +170,11 @@ pub fn install() -> Result<(), JsValue> {
|
|||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> js_sys::Promise>::new(|path: String| {
|
||||
future_to_promise(caught(async move {
|
||||
future_to_promise(async move {
|
||||
let blob = crate::fs::blob(&path).ok_or_else(|| JsValue::from_str(&format!("{path}: not found")))?;
|
||||
let buf = JsFuture::from(blob.array_buffer()).await?;
|
||||
Ok(js_sys::Uint8Array::new(&buf).into())
|
||||
}))
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
|
|
@ -191,10 +191,10 @@ pub fn install() -> Result<(), JsValue> {
|
|||
&obj,
|
||||
"importUrl",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|url: String, name: JsValue| {
|
||||
future_to_promise(caught(async move {
|
||||
future_to_promise(async move {
|
||||
let f = fetch_file(&url, name.as_string()).await?;
|
||||
Ok(to_js(&crate::import::import_files(vec![f]).await))
|
||||
}))
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
|
|
@ -202,13 +202,13 @@ pub fn install() -> Result<(), JsValue> {
|
|||
&obj,
|
||||
"openProject",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|src: JsValue| {
|
||||
future_to_promise(caught(async move {
|
||||
future_to_promise(async move {
|
||||
let file = match src.as_string() {
|
||||
Some(url) => fetch_file(&url, None).await?,
|
||||
None => files_of(&src).into_iter().next().ok_or("need a File or URL")?,
|
||||
};
|
||||
crate::import::open_project_file(file).await.map(|v| to_js(&v)).map_err(|e| JsValue::from(js_sys::Error::new(&e)))
|
||||
}))
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
|
|
@ -216,19 +216,3 @@ pub fn install() -> Result<(), JsValue> {
|
|||
js_sys::Reflect::set(&w, &"filmcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `f`, rejecting with an [`as_error`] `Error`.
|
||||
async fn caught(f: impl std::future::Future<Output = Result<JsValue, JsValue>>) -> Result<JsValue, JsValue> {
|
||||
f.await.map_err(as_error)
|
||||
}
|
||||
|
||||
/// Every member rejects with an `Error` (#90), as `execute` and `request` do, so `e.message` and
|
||||
/// `e instanceof Error` work: a plain string (a fetch status, "not found") becomes one, and an
|
||||
/// `Error` passes through unchanged.
|
||||
fn as_error(e: JsValue) -> JsValue {
|
||||
if e.is_instance_of::<js_sys::Error>() {
|
||||
return e;
|
||||
}
|
||||
let msg = e.as_string().unwrap_or_else(|| format!("{e:?}"));
|
||||
js_sys::Error::new(&msg).into()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the
|
||||
//! anchor clicks live in [`crate::fs::download`]).
|
||||
//!
|
||||
//! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export
|
||||
//! (one file per frame) delivered only about half of its frames (#378). Each download is given a
|
||||
//! slot [`GAP_MS`] after the previous one.
|
||||
|
||||
/// Milliseconds between two download clicks.
|
||||
pub const GAP_MS: f64 = 125.0;
|
||||
|
||||
/// Hands out click times.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Pacer {
|
||||
next_free: f64,
|
||||
}
|
||||
|
||||
impl Pacer {
|
||||
/// Reserve the next slot at time `now` (ms); returns how long to wait before clicking.
|
||||
pub fn reserve(&mut self, now: f64) -> f64 {
|
||||
let start = now.max(self.next_free);
|
||||
self.next_free = start + GAP_MS;
|
||||
start - now
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,6 @@ struct Fs {
|
|||
|
||||
thread_local! {
|
||||
static FS: RefCell<Fs> = RefCell::new(Fs::default());
|
||||
static DOWNLOADS: RefCell<crate::download_pacing::Pacer> = RefCell::new(crate::download_pacing::Pacer::default());
|
||||
}
|
||||
|
||||
/// Wake-up hook for arriving data.
|
||||
|
|
@ -71,8 +70,9 @@ fn file_name(path: &str) -> &str {
|
|||
path.rsplit('/').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Register a browser `File` (or named `Blob`) at an unused virtual path; collisions get numbered names.
|
||||
/// Name + size do not identify content: replacing an occupied path changes every project item referencing it.
|
||||
/// Register a browser `File` (or `Blob` with a name) and return its virtual path. A file with
|
||||
/// the same name and size as an existing entry replaces it (the same file picked again keeps
|
||||
/// its path, so projects referencing it come back online); otherwise a numbered name is used.
|
||||
pub fn register_blob(name: &str, blob: web_sys::Blob) -> String {
|
||||
let size = blob.size() as u64;
|
||||
let clean: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
|
||||
|
|
@ -85,11 +85,18 @@ pub fn register_blob(name: &str, blob: web_sys::Blob) -> String {
|
|||
let mut n = 1;
|
||||
let path = loop {
|
||||
let p = if n == 1 { format!("/files/{clean}") } else { format!("/files/{stem} ({n}){ext}") };
|
||||
if !fs.entries.contains_key(&p) {
|
||||
break p;
|
||||
match fs.entries.get(&p) {
|
||||
None => break p,
|
||||
Some(Entry::Blob { size: s, .. }) if *s == size => break p,
|
||||
Some(Entry::Mem(b)) if b.len() as u64 == size => break p,
|
||||
_ => n += 1,
|
||||
}
|
||||
n += 1;
|
||||
};
|
||||
if let Some(Entry::Blob { id, .. }) = fs.entries.get(&path) {
|
||||
let id = *id;
|
||||
fs.blobs.remove(&id);
|
||||
fs.chunks.retain(|k, _| k.0 != id);
|
||||
}
|
||||
let id = fs.next_id;
|
||||
fs.next_id += 1;
|
||||
fs.blobs.insert(id, blob);
|
||||
|
|
@ -406,8 +413,7 @@ impl Services for WebServices {
|
|||
}
|
||||
}
|
||||
|
||||
/// Offer bytes as a browser download named `name`. Downloads are clicked at least
|
||||
/// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378).
|
||||
/// Offer bytes as a browser download named `name`.
|
||||
pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let blob = bytes_to_blob(data, "application/octet-stream")?;
|
||||
|
|
@ -416,20 +422,10 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
|||
a.set_href(&url);
|
||||
a.set_download(file_name(name));
|
||||
a.style().set_property("display", "none")?;
|
||||
let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now()));
|
||||
let name = name.to_owned();
|
||||
doc.body().ok_or("no body")?.append_child(&a)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if delay > 0.0 {
|
||||
sleep(delay as i32).await;
|
||||
}
|
||||
let clicked = match doc.body() {
|
||||
Some(body) => body.append_child(&a).map(|_| a.click()),
|
||||
None => Err("no body".into()),
|
||||
};
|
||||
a.remove();
|
||||
if let Err(e) = clicked {
|
||||
log::warn!("download {name}: {e:?}");
|
||||
}
|
||||
sleep(60_000).await;
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use std::cell::RefCell;
|
|||
use std::rc::Rc;
|
||||
|
||||
use filmcraft_engine::Services;
|
||||
use filmcraft_ui_egui::{FilmcraftApp, RelinkHint};
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
|
@ -81,12 +81,12 @@ async fn prewarm(path: &str, size: u64) {
|
|||
}
|
||||
|
||||
/// `file.import` one path, retried while its bytes are loading.
|
||||
async fn import_path(path: String, bin: u64) -> Result<Value, String> {
|
||||
async fn import_path(path: String) -> Result<Value, String> {
|
||||
for _ in 0..2000 {
|
||||
let p = path.clone();
|
||||
let (r, pending) = on_ui(move |w, _| {
|
||||
let _ = filmcraft_media::pending::take();
|
||||
let r = w.app.session.execute("file.import", json!({"paths": [p], "bin": bin})).map_err(|e| e.to_string());
|
||||
let r = w.app.session.execute("file.import", json!({"paths": [p]})).map_err(|e| e.to_string());
|
||||
(r, filmcraft_media::pending::take())
|
||||
})
|
||||
.await;
|
||||
|
|
@ -105,8 +105,6 @@ async fn import_path(path: String, bin: u64) -> Result<Value, String> {
|
|||
|
||||
/// Import browser files (no copies): returns `{items, errors, paths}`.
|
||||
pub async fn import_files(files: Vec<web_sys::File>) -> Value {
|
||||
// Capture once per batch: navigation while prewarming must not redirect pending imports.
|
||||
let bin = on_ui(|w, _| w.app.import_bin().0).await;
|
||||
let mut items = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut paths = Vec::new();
|
||||
|
|
@ -115,7 +113,7 @@ pub async fn import_files(files: Vec<web_sys::File>) -> Value {
|
|||
let size = f.size() as u64;
|
||||
let path = fs::register_blob(&name, f.clone().into());
|
||||
prewarm(&path, size).await;
|
||||
match import_path(path.clone(), bin).await {
|
||||
match import_path(path.clone()).await {
|
||||
Ok(v) => {
|
||||
items.extend(v.get("items").and_then(Value::as_array).cloned().unwrap_or_default());
|
||||
errors.extend(v.get("errors").and_then(Value::as_array).cloned().unwrap_or_default());
|
||||
|
|
@ -155,43 +153,12 @@ pub fn take_files(files: Vec<web_sys::File>) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Files a picker returned: imported, or with a relink hint, the first one is registered under
|
||||
/// `/files/<name>` and the hint's command runs on it (#111).
|
||||
fn picked(files: Vec<web_sys::File>, relink: Option<RelinkHint>) {
|
||||
let Some(hint) = relink else { return take_files(files) };
|
||||
let Some(f) = files.into_iter().next() else { return };
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let path = fs::register_blob(&f.name(), f.clone().into());
|
||||
crate::recovery::keep_media(&path, &f);
|
||||
let mut params = hint.params;
|
||||
if !params.is_object() {
|
||||
params = json!({});
|
||||
}
|
||||
params["path"] = json!(path);
|
||||
let command = hint.command;
|
||||
let outcome = on_ui(move |w, _| w.app.session.execute(&command, params).map_err(|e| e.to_string())).await;
|
||||
crate::post(move |w, _| match outcome {
|
||||
Ok(v) => {
|
||||
if let Some(n) = v.get("relinked").and_then(Value::as_array).map(Vec::len) {
|
||||
w.app.status(format!("Linked {n} clip(s)"));
|
||||
}
|
||||
}
|
||||
Err(e) => w.app.status(e),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn has_fsa_picker() -> bool {
|
||||
web_sys::window().is_some_and(|w| js_sys::Reflect::has(&w, &"showOpenFilePicker".into()).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Let the user pick files: `showOpenFilePicker` when available, else a file input.
|
||||
pub fn pick(exts: &[&str], multiple: bool) {
|
||||
pick_then(exts, multiple, None);
|
||||
}
|
||||
|
||||
/// [`pick`], handing the files to [`picked`]: a cancelled picker leaves nothing pending.
|
||||
fn pick_then(exts: &[&str], multiple: bool, relink: Option<RelinkHint>) {
|
||||
let accept: Vec<String> = exts.iter().map(|e| format!(".{e}")).collect();
|
||||
if has_fsa_picker() {
|
||||
let opts = js_sys::Object::new();
|
||||
|
|
@ -211,7 +178,7 @@ fn pick_then(exts: &[&str], multiple: bool, relink: Option<RelinkHint>) {
|
|||
files.push(f);
|
||||
}
|
||||
}
|
||||
picked(files, relink);
|
||||
take_files(files);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -223,7 +190,7 @@ fn pick_then(exts: &[&str], multiple: bool, relink: Option<RelinkHint>) {
|
|||
let inp = input.clone();
|
||||
let on_change = Closure::<dyn FnMut()>::new(move || {
|
||||
if let Some(list) = inp.files() {
|
||||
picked((0..list.length()).filter_map(|i| list.get(i)).collect(), relink.clone());
|
||||
take_files((0..list.length()).filter_map(|i| list.get(i)).collect());
|
||||
}
|
||||
});
|
||||
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||
|
|
@ -263,16 +230,5 @@ 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| {
|
||||
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<RelinkHint>| {
|
||||
if hint.is_some() {
|
||||
pick_then(exts, false, hint);
|
||||
}
|
||||
None
|
||||
}));
|
||||
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| Some(format!("/exports/{name}"))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,10 @@
|
|||
|
||||
pub mod api;
|
||||
pub mod audio;
|
||||
pub mod download_pacing;
|
||||
pub mod fs;
|
||||
pub mod import;
|
||||
pub mod opfs;
|
||||
pub mod recovery;
|
||||
pub mod recovery_policy;
|
||||
pub mod webcodecs;
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
|
@ -60,13 +58,6 @@ pub fn repaint() {
|
|||
});
|
||||
}
|
||||
|
||||
/// Give a host that builds its own [`WebApp`] the egui context that [`post`] and [`repaint`] wake.
|
||||
/// [`start`] calls it; an embedding app that runs `WebApp` in its own eframe runner calls it once
|
||||
/// from its app creator, or posted actions and JS API requests wait for the next input event.
|
||||
pub fn set_context(ctx: egui::Context) {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
/// Environment facts reported by `filmcraft.info()` (backend, isolation, codecs…).
|
||||
pub fn set_info(key: &str, v: serde_json::Value) {
|
||||
INFO.with(|i| {
|
||||
|
|
@ -159,7 +150,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
|||
canvas,
|
||||
web_options,
|
||||
Box::new(move |cc| {
|
||||
set_context(cc.egui_ctx.clone());
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
fs::set_on_data(move || ctx.request_repaint());
|
||||
fs::set_on_write(|path, data| {
|
||||
|
|
@ -186,9 +177,6 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
|||
}
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
import::install_hooks(&mut app);
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218): the browser's languages.
|
||||
app.hooks.system_languages =
|
||||
Some(Box::new(|| web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default()));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
|
|
|
|||
|
|
@ -10,13 +10,8 @@ use wasm_bindgen::{JsCast, JsValue};
|
|||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle};
|
||||
|
||||
/// Called with whether a queued write reached OPFS.
|
||||
pub type Done = Box<dyn FnOnce(bool)>;
|
||||
|
||||
thread_local! {
|
||||
/// Pending writes per path (bytes and the callbacks of every write coalesced into them), and
|
||||
/// whether the writer task is running.
|
||||
static QUEUE: RefCell<(BTreeMap<String, (Vec<u8>, Vec<Done>)>, bool)> = const { RefCell::new((BTreeMap::new(), false)) };
|
||||
static QUEUE: RefCell<(BTreeMap<String, Vec<u8>>, bool)> = const { RefCell::new((BTreeMap::new(), false)) };
|
||||
}
|
||||
|
||||
/// Whether this browser has OPFS (`navigator.storage.getDirectory`).
|
||||
|
|
@ -65,17 +60,9 @@ async fn write_now(rel: &str, data: &[u8]) -> Result<(), JsValue> {
|
|||
|
||||
/// Queue a write of `data` to `rel` (relative to the OPFS root).
|
||||
pub fn write(rel: String, data: Vec<u8>) {
|
||||
write_then(rel, data, None);
|
||||
}
|
||||
|
||||
/// [`write`], then call `done` with whether it succeeded. A write replaced by a newer one to the
|
||||
/// same path reports the newer one's result.
|
||||
pub fn write_then(rel: String, data: Vec<u8>, done: Option<Done>) {
|
||||
let start = QUEUE.with(|q| {
|
||||
let mut q = q.borrow_mut();
|
||||
let e = q.0.entry(rel).or_default();
|
||||
e.0 = data;
|
||||
e.1.extend(done);
|
||||
q.0.insert(rel, data);
|
||||
!std::mem::replace(&mut q.1, true)
|
||||
});
|
||||
if !start {
|
||||
|
|
@ -94,14 +81,10 @@ pub fn write_then(rel: String, data: Vec<u8>, done: Option<Done>) {
|
|||
}
|
||||
}
|
||||
});
|
||||
let Some((rel, (data, done))) = next else { break };
|
||||
let r = write_now(&rel, &data).await;
|
||||
if let Err(e) = &r {
|
||||
let Some((rel, data)) = next else { break };
|
||||
if let Err(e) = write_now(&rel, &data).await {
|
||||
log::warn!("OPFS write {rel}: {e:?}");
|
||||
}
|
||||
for d in done {
|
||||
d(r.is_ok());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,32 +6,24 @@
|
|||
//! - Imported media are copied to `media/<name>` (the browser streams the copy; nothing goes
|
||||
//! through wasm memory), so the snapshot's `/files/<name>` references resolve after a reload.
|
||||
//! - At start-up a dirty snapshot is reopened (`?norecover` skips it, `?fresh` also skips the media).
|
||||
//! A skipped snapshot stays dirty until this session writes its own ([`crate::recovery_policy`]).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use filmcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::opfs;
|
||||
use crate::recovery_policy::{Action, Policy};
|
||||
|
||||
const SNAPSHOT: &str = "recovery/snapshot.fcproj";
|
||||
const META: &str = "recovery/meta.json";
|
||||
/// Seconds between snapshots of a changing project.
|
||||
const INTERVAL_S: f64 = 5.0;
|
||||
/// Media larger than this are not copied to OPFS.
|
||||
const KEEP_MAX: f64 = 4.0 * 1024.0 * 1024.0 * 1024.0;
|
||||
|
||||
/// A finished OPFS write, reported back to the next [`Autosave::tick`].
|
||||
enum Outcome {
|
||||
Snapshot { revision: u64, ok: bool },
|
||||
Clean { ok: bool },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Autosave {
|
||||
policy: Policy,
|
||||
outcomes: Rc<RefCell<Vec<Outcome>>>,
|
||||
last_revision: u64,
|
||||
last_time: f64,
|
||||
last_dirty: Option<bool>,
|
||||
}
|
||||
|
||||
impl Autosave {
|
||||
|
|
@ -41,44 +33,22 @@ impl Autosave {
|
|||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
for o in self.outcomes.borrow_mut().drain(..) {
|
||||
match o {
|
||||
Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now),
|
||||
Outcome::Clean { ok } => self.policy.cleaned(ok, now),
|
||||
}
|
||||
}
|
||||
match self.policy.tick(now, s.revision, s.saved_revision) {
|
||||
Action::Nothing => {}
|
||||
Action::WakeAfter(secs) => ctx.request_repaint_after(std::time::Duration::from_secs_f64(secs)),
|
||||
Action::WriteSnapshot(revision) => {
|
||||
// Snapshot and meta both have to land; either failing retries the pair.
|
||||
let failed = Rc::new(Cell::new(false));
|
||||
let left = Rc::new(Cell::new(2u8));
|
||||
let done = |out: Rc<RefCell<Vec<Outcome>>>, failed: Rc<Cell<bool>>, left: Rc<Cell<u8>>, ctx: egui::Context| -> opfs::Done {
|
||||
Box::new(move |ok| {
|
||||
failed.set(failed.get() || !ok);
|
||||
left.set(left.get().saturating_sub(1));
|
||||
if left.get() == 0 {
|
||||
out.borrow_mut().push(Outcome::Snapshot { revision, ok: !failed.get() });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
})
|
||||
};
|
||||
let dirty = s.revision != s.saved_revision;
|
||||
if dirty {
|
||||
if s.revision != self.last_revision && now - self.last_time >= INTERVAL_S {
|
||||
opfs::write(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false));
|
||||
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()});
|
||||
let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone());
|
||||
let d2 = done(self.outcomes.clone(), failed, left, ctx.clone());
|
||||
opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1));
|
||||
opfs::write_then(META.into(), meta.to_string().into_bytes(), Some(d2));
|
||||
}
|
||||
Action::WriteClean => {
|
||||
let (out, ctx) = (self.outcomes.clone(), ctx.clone());
|
||||
let done: opfs::Done = Box::new(move |ok| {
|
||||
out.borrow_mut().push(Outcome::Clean { ok });
|
||||
ctx.request_repaint();
|
||||
});
|
||||
opfs::write_then(META.into(), json!({"dirty": false}).to_string().into_bytes(), Some(done));
|
||||
opfs::write(META.into(), meta.to_string().into_bytes());
|
||||
self.last_revision = s.revision;
|
||||
self.last_time = now;
|
||||
} else if s.revision != self.last_revision {
|
||||
// the UI may go idle: wake up when the snapshot is due
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64((INTERVAL_S - (now - self.last_time)).max(0.05)));
|
||||
}
|
||||
} else if self.last_dirty != Some(false) {
|
||||
opfs::write(META.into(), json!({"dirty": false}).to_string().into_bytes());
|
||||
}
|
||||
self.last_dirty = Some(dirty);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
//! When the web recovery snapshot is written or marked clean (pure, so it is tested natively in
|
||||
//! `tests/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
|
||||
//!
|
||||
//! - A dirty project is snapshotted at most every [`INTERVAL_S`] seconds, once per revision. A write
|
||||
//! that fails is retried after the interval, without waiting for another edit (#197).
|
||||
//! - The meta is marked clean only to retire a snapshot this session wrote. A snapshot left by an
|
||||
//! earlier session that was not reopened (`?norecover`, `?fresh`) stays dirty (#193).
|
||||
|
||||
/// Seconds between snapshots of a changing project.
|
||||
pub const INTERVAL_S: f64 = 5.0;
|
||||
|
||||
/// What the caller should do this frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
Nothing,
|
||||
/// Write the snapshot and a dirty meta for this revision, then report with [`Policy::written`].
|
||||
WriteSnapshot(u64),
|
||||
/// Write a clean meta, then report with [`Policy::cleaned`].
|
||||
WriteClean,
|
||||
/// Nothing now; wake up after this many seconds.
|
||||
WakeAfter(f64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Policy {
|
||||
/// Revision whose snapshot was last written or is being written.
|
||||
last_revision: Option<u64>,
|
||||
last_time: Option<f64>,
|
||||
/// This session wrote a dirty meta that is not yet retired.
|
||||
owns_dirty_meta: bool,
|
||||
/// A clean-meta write is in flight.
|
||||
cleaning: bool,
|
||||
/// Snapshot writes started so far, and how many had started when the clean write did (a
|
||||
/// snapshot started after it makes the meta dirty again).
|
||||
snapshots: u64,
|
||||
snapshots_at_clean: u64,
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
/// Called every frame with the session's revisions and the time in seconds.
|
||||
pub fn tick(&mut self, now: f64, revision: u64, saved_revision: u64) -> Action {
|
||||
if revision != saved_revision {
|
||||
if self.last_revision == Some(revision) {
|
||||
return Action::Nothing;
|
||||
}
|
||||
let since = self.last_time.map_or(f64::INFINITY, |t| now - t);
|
||||
if since >= INTERVAL_S {
|
||||
self.last_revision = Some(revision);
|
||||
self.last_time = Some(now);
|
||||
self.owns_dirty_meta = true;
|
||||
self.snapshots += 1;
|
||||
return Action::WriteSnapshot(revision);
|
||||
}
|
||||
return Action::WakeAfter((INTERVAL_S - since).max(0.05));
|
||||
}
|
||||
if self.owns_dirty_meta && !self.cleaning {
|
||||
let since = self.last_time.map_or(f64::INFINITY, |t| now - t);
|
||||
if self.last_revision.is_some() || since >= INTERVAL_S {
|
||||
self.cleaning = true;
|
||||
self.snapshots_at_clean = self.snapshots;
|
||||
return Action::WriteClean;
|
||||
}
|
||||
return Action::WakeAfter((INTERVAL_S - since).max(0.05));
|
||||
}
|
||||
Action::Nothing
|
||||
}
|
||||
|
||||
/// The snapshot write for `revision` finished.
|
||||
pub fn written(&mut self, revision: u64, ok: bool, now: f64) {
|
||||
if !ok && self.last_revision == Some(revision) {
|
||||
// retry after the interval, even if nothing else changes
|
||||
self.last_revision = None;
|
||||
self.last_time = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The clean-meta write finished.
|
||||
pub fn cleaned(&mut self, ok: bool, now: f64) {
|
||||
self.cleaning = false;
|
||||
if ok {
|
||||
self.owns_dirty_meta = self.snapshots != self.snapshots_at_clean;
|
||||
} else {
|
||||
// retry after the interval
|
||||
self.last_revision = None;
|
||||
self.last_time = Some(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@
|
|||
//! the job on a later frame, when the decoder's output callback has delivered the picture.
|
||||
//! - A session that has not delivered the frame it was started for is not restarted by other
|
||||
//! requests (thumbnails and the monitor would otherwise keep resetting each other).
|
||||
//! - Native YUV planes feed the desktop frame/compositor contract. Unsupported browser formats or geometry use Canvas RGBA.
|
||||
//! - Outputs are converted to RGBA by drawing the `VideoFrame` on an `OffscreenCanvas` (the
|
||||
//! browser applies the stream's colour description).
|
||||
//! - Any decoder error or unsupported configuration switches the source to our decoder.
|
||||
//!
|
||||
//! `?nowebcodecs` disables it. Support is probed at start-up with `VideoDecoder.isConfigSupported`.
|
||||
|
|
@ -33,20 +34,6 @@ use wasm_bindgen::JsCast;
|
|||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
mod pixels;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Transfers {
|
||||
native: u64,
|
||||
canvas: u64,
|
||||
closed: u64,
|
||||
stale: u64,
|
||||
bytes: u64,
|
||||
native_ms: f64,
|
||||
canvas_ms: f64,
|
||||
reasons: BTreeMap<&'static str, u64>,
|
||||
}
|
||||
|
||||
/// Samples fed past the wanted one (decoders hold pictures for reordering).
|
||||
const LOOKAHEAD: usize = 8;
|
||||
/// Decoded frames kept per source.
|
||||
|
|
@ -65,7 +52,6 @@ thread_local! {
|
|||
static DECODED: Cell<u64> = const { Cell::new(0) };
|
||||
static FALLBACKS: Cell<u64> = const { Cell::new(0) };
|
||||
static SOURCES: Cell<u64> = const { Cell::new(0) };
|
||||
static TRANSFERS: RefCell<Transfers> = RefCell::new(Transfers::default());
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
|
|
@ -174,15 +160,11 @@ pub fn stats() -> serde_json::Value {
|
|||
.iter()
|
||||
.map(|(id, s)| {
|
||||
json!({"id": id, "start": s.start, "next": s.next, "target": s.target, "needsKey": s.needs_key, "flushing": s.flushing.get(),
|
||||
"errored": s.errored.get(), "queue": s.queue(), "copies": s.copying.get(), "idleMs": now_ms() - s.last_output.get()})
|
||||
"errored": s.errored.get(), "queue": s.queue(), "idleMs": now_ms() - s.last_output.get()})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let transfers = TRANSFERS.with(|s| {
|
||||
let s = s.borrow();
|
||||
json!({"nativeFrames":s.native,"canvasFrames":s.canvas,"closedFrames":s.closed,"staleFrames":s.stale,"nativeBytes":s.bytes,"nativeMs":s.native_ms,"canvasMs":s.canvas_ms,"canvasReasons":s.reasons})
|
||||
});
|
||||
json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions,"transfers":transfers})
|
||||
json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions})
|
||||
}
|
||||
|
||||
/// A [`SharedReader`] as the demuxer's byte source.
|
||||
|
|
@ -228,8 +210,6 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
|||
});
|
||||
SOURCES.with(|c| c.set(c.get() + 1));
|
||||
log::info!("{name}: video decoded with WebCodecs ({codec})");
|
||||
let color = info.video.as_ref().map(|v| v.color).unwrap_or_default();
|
||||
let par = info.video.as_ref().map(|v| v.par).unwrap_or((1, 1));
|
||||
Some(Ok(Arc::new(WcSource {
|
||||
inner,
|
||||
info,
|
||||
|
|
@ -237,12 +217,11 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
|||
file,
|
||||
track,
|
||||
id,
|
||||
config: Config { codec, description, width: w, height: h, rotation, color, par },
|
||||
config: Config { codec, description, width: w, height: h, rotation },
|
||||
state: Mutex::new(Cache::default()),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Config {
|
||||
codec: String,
|
||||
description: Option<Vec<u8>>,
|
||||
|
|
@ -250,8 +229,6 @@ struct Config {
|
|||
height: u32,
|
||||
/// Clockwise quarter turns from the track matrix, applied to decoded frames.
|
||||
rotation: u8,
|
||||
color: filmcraft_color::ColorInfo,
|
||||
par: (u32, u32),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -276,9 +253,6 @@ struct Session {
|
|||
/// Highest presentation time output since the session (re)started.
|
||||
out_max: Rc<Cell<i64>>,
|
||||
out: Rc<RefCell<Vec<(i64, VideoFrame)>>>,
|
||||
copying: Rc<Cell<u32>>,
|
||||
generation: Rc<Cell<u64>>,
|
||||
alive: Rc<Cell<bool>>,
|
||||
_closures: (Closure<dyn FnMut(JsValue)>, Closure<dyn FnMut(JsValue)>),
|
||||
}
|
||||
|
||||
|
|
@ -312,65 +286,17 @@ impl Session {
|
|||
let errored = Rc::new(Cell::new(false));
|
||||
let last_output = Rc::new(Cell::new(now_ms()));
|
||||
let out_max = Rc::new(Cell::new(i64::MIN));
|
||||
let copying = Rc::new(Cell::new(0u32));
|
||||
let generation = Rc::new(Cell::new(0u64));
|
||||
let alive = Rc::new(Cell::new(true));
|
||||
let (o, lo, om) = (out.clone(), last_output.clone(), out_max.clone());
|
||||
let (copies, epoch, active, config, errors) = (copying.clone(), generation.clone(), alive.clone(), cfg.clone(), errored.clone());
|
||||
let on_output = Closure::<dyn FnMut(JsValue)>::new(move |frame: JsValue| {
|
||||
let ts = get(&frame, "timestamp").as_f64().unwrap_or(0.0) as i64;
|
||||
let expected = epoch.get();
|
||||
let (o, lo, om, copies, epoch, active, config, errors) =
|
||||
(o.clone(), lo.clone(), om.clone(), copies.clone(), epoch.clone(), active.clone(), config.clone(), errors.clone());
|
||||
copies.set(copies.get().saturating_add(1));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let begin = now_ms();
|
||||
let converted = match pixels::read(&frame, &config).await {
|
||||
Ok((f, bytes)) => {
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.native = s.native.saturating_add(1);
|
||||
s.bytes = s.bytes.saturating_add(bytes as u64);
|
||||
s.native_ms += now_ms() - begin;
|
||||
});
|
||||
Some(f)
|
||||
}
|
||||
Err(reason) => {
|
||||
let begin = now_ms();
|
||||
let f = to_rgba(&frame);
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.canvas = s.canvas.saturating_add(1);
|
||||
s.canvas_ms += now_ms() - begin;
|
||||
let count = s.reasons.entry(reason).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
});
|
||||
f
|
||||
}
|
||||
};
|
||||
let _ = call(&frame, "close", &[]);
|
||||
copies.set(copies.get().saturating_sub(1));
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.closed = s.closed.saturating_add(1);
|
||||
});
|
||||
if !active.get() || epoch.get() != expected {
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.stale = s.stale.saturating_add(1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Some(f) = converted {
|
||||
o.borrow_mut().push((ts, f));
|
||||
om.set(om.get().max(ts));
|
||||
DECODED.with(|c| c.set(c.get().saturating_add(1)));
|
||||
} else {
|
||||
errors.set(true);
|
||||
}
|
||||
lo.set(now_ms());
|
||||
crate::repaint();
|
||||
});
|
||||
om.set(om.get().max(ts));
|
||||
if let Some(f) = to_rgba(&frame) {
|
||||
o.borrow_mut().push((ts, f));
|
||||
DECODED.with(|c| c.set(c.get() + 1));
|
||||
}
|
||||
let _ = call(&frame, "close", &[]);
|
||||
lo.set(now_ms());
|
||||
crate::repaint();
|
||||
});
|
||||
let e = errored.clone();
|
||||
let on_error = Closure::<dyn FnMut(JsValue)>::new(move |err: JsValue| {
|
||||
|
|
@ -391,9 +317,6 @@ impl Session {
|
|||
last_output,
|
||||
out_max,
|
||||
out,
|
||||
copying,
|
||||
generation,
|
||||
alive,
|
||||
_closures: (on_output, on_error),
|
||||
};
|
||||
s.configure(cfg)?;
|
||||
|
|
@ -418,14 +341,6 @@ impl Session {
|
|||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
self.alive.set(false);
|
||||
self.generation.set(self.generation.get().wrapping_add(1));
|
||||
let _ = call(&self.decoder, "close", &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An MP4/MOV source whose video is decoded by the browser.
|
||||
pub struct WcSource {
|
||||
inner: SharedSource,
|
||||
|
|
@ -473,15 +388,12 @@ impl WcSource {
|
|||
// already output but the cache dropped needs a restart).
|
||||
let covers = !s.needs_key && s.start <= i && key <= s.next && (i >= s.next || want > s.out_max.get());
|
||||
if !covers {
|
||||
// Don't interrupt a session that is still working towards its own frame, unless it
|
||||
// has gone quiet for STALE_MS (a copyTo that never settles must not wedge it).
|
||||
let fresh = now_ms() - s.last_output.get() < STALE_MS;
|
||||
let busy = fresh && (s.copying.get() > 0 || (!s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target));
|
||||
// Don't interrupt a session that is still working towards its own frame.
|
||||
let busy = !s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target && now_ms() - s.last_output.get() < STALE_MS;
|
||||
if busy {
|
||||
return Drive::Waiting;
|
||||
}
|
||||
if !s.needs_key || s.next > 0 {
|
||||
s.generation.set(s.generation.get().wrapping_add(1));
|
||||
let _ = call(&s.decoder, "reset", &[]);
|
||||
if s.configure(&self.config).is_err() {
|
||||
return Drive::Failed;
|
||||
|
|
@ -498,7 +410,7 @@ impl WcSource {
|
|||
s.target = want;
|
||||
}
|
||||
let upto = (i + LOOKAHEAD).min(n - 1);
|
||||
while s.next <= upto && s.queue().saturating_add(s.copying.get()) < MAX_QUEUE {
|
||||
while s.next <= upto && s.queue() < MAX_QUEUE {
|
||||
let smp = &t.samples[s.next];
|
||||
let mut data = vec![0u8; smp.size as usize];
|
||||
match self.reader.read_at(smp.offset, &mut data) {
|
||||
|
|
@ -568,8 +480,10 @@ impl WcSource {
|
|||
impl Drop for WcSource {
|
||||
fn drop(&mut self) {
|
||||
SESSIONS.with(|ss| {
|
||||
if let Ok(mut ss) = ss.try_borrow_mut() {
|
||||
ss.remove(&self.id);
|
||||
if let Ok(mut ss) = ss.try_borrow_mut()
|
||||
&& let Some(s) = ss.remove(&self.id)
|
||||
{
|
||||
let _ = call(&s.decoder, "close", &[]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -588,8 +502,7 @@ impl MediaSource for WcSource {
|
|||
}
|
||||
let t = &self.file.tracks[self.track];
|
||||
let n = t.samples.len();
|
||||
// nearest, as `Mp4Source` looks frames up (sample times are rounded to the timescale)
|
||||
let target = req.time.max(Tick::ZERO).to_rational_round(1, t.timescale.max(1) as i64);
|
||||
let target = req.time.max(Tick::ZERO).to_rational_floor(1, t.timescale.max(1) as i64);
|
||||
let i = t.sample_at_presentation_time(target).unwrap_or(n - 1).min(n - 1);
|
||||
let want = t.samples[i].pts;
|
||||
self.collect(&mut c, want);
|
||||
|
|
@ -618,7 +531,4 @@ impl MediaSource for WcSource {
|
|||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
self.inner.audio(start, frames, sample_rate)
|
||||
}
|
||||
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
self.inner.audio_stream(stream, start, frames, sample_rate)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
//! Browser planes -> core frames; no color conversion or chroma reconstruction here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||||
use filmcraft_time::Tick;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::{Config, call, get, obj};
|
||||
|
||||
// One 8K 4:4:4 16-bit picture + alpha fits within 256 MiB; cap browser-driven allocations.
|
||||
const MAX_COPY_BYTES: u32 = 256 * 1024 * 1024;
|
||||
|
||||
fn integer(value: JsValue) -> Option<u32> {
|
||||
let n = value.as_f64()?;
|
||||
if n.is_finite() && n >= 0.0 && n <= u32::MAX as f64 && n.fract() == 0.0 { Some(n as u32) } else { None }
|
||||
}
|
||||
|
||||
fn color(frame: &JsValue, fallback: ColorInfo) -> Option<ColorInfo> {
|
||||
let c = get(frame, "colorSpace");
|
||||
let matrix = match get(&c, "matrix").as_string().as_deref() {
|
||||
Some("bt709") => Matrix::Bt709,
|
||||
Some("bt470bg" | "smpte170m") => Matrix::Bt601,
|
||||
Some("bt2020-ncl") => Matrix::Bt2020Ncl,
|
||||
None => fallback.matrix,
|
||||
_ => return None,
|
||||
};
|
||||
let transfer = match get(&c, "transfer").as_string().as_deref() {
|
||||
Some("bt709") => Transfer::Bt709,
|
||||
Some("iec61966-2-1") => Transfer::Srgb,
|
||||
Some("smpte2084") => Transfer::Pq,
|
||||
Some("arib-std-b67") => Transfer::Hlg,
|
||||
None => fallback.transfer,
|
||||
_ => return None,
|
||||
};
|
||||
let primaries = match get(&c, "primaries").as_string().as_deref() {
|
||||
Some("bt709") => Primaries::Bt709,
|
||||
Some("bt470bg") => Primaries::Bt601_625,
|
||||
Some("smpte170m") => Primaries::Bt601_525,
|
||||
Some("bt2020") => Primaries::Bt2020,
|
||||
None => fallback.primaries,
|
||||
_ => return None,
|
||||
};
|
||||
let range = match get(&c, "fullRange").as_bool() {
|
||||
Some(true) => Range::Full,
|
||||
Some(false) => Range::Limited,
|
||||
None => fallback.range,
|
||||
};
|
||||
Some(ColorInfo { matrix, transfer, primaries, range })
|
||||
}
|
||||
|
||||
fn plane(raw: &js_sys::Uint8Array, layout: &JsValue, width: u32, height: u32) -> Result<Vec<u8>, &'static str> {
|
||||
let offset = integer(get(layout, "offset")).ok_or("layout")?;
|
||||
let stride = integer(get(layout, "stride")).filter(|s| *s >= width).ok_or("layout")?;
|
||||
let end =
|
||||
height.checked_sub(1).and_then(|h| h.checked_mul(stride)).and_then(|n| offset.checked_add(n)).and_then(|n| n.checked_add(width)).ok_or("layout")?;
|
||||
if end > raw.length() {
|
||||
return Err("layout");
|
||||
}
|
||||
let len = width.checked_mul(height).filter(|n| *n <= MAX_COPY_BYTES).ok_or("allocation")? as usize;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(len).map_err(|_| "memory")?;
|
||||
out.resize(len, 0);
|
||||
if stride == width {
|
||||
raw.subarray(offset, end).copy_to(&mut out);
|
||||
} else {
|
||||
for (y, row) in out.chunks_exact_mut(width as usize).enumerate() {
|
||||
// Checked for the last row above; earlier rows are smaller, but stay checked anyway.
|
||||
let start = u32::try_from(y).ok().and_then(|y| y.checked_mul(stride)).and_then(|n| offset.checked_add(n)).ok_or("layout")?;
|
||||
let row_end = start.checked_add(width).filter(|e| *e <= end).ok_or("layout")?;
|
||||
raw.subarray(start, row_end).copy_to(row);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy native 8-bit YUV planes; unsupported formats/geometry return a counted Canvas fallback reason.
|
||||
pub(super) async fn read(frame: &JsValue, cfg: &Config) -> Result<(VideoFrame, u32), &'static str> {
|
||||
let format = get(frame, "format").as_string().ok_or("format")?;
|
||||
let (chroma, alpha, interleaved) = match format.as_str() {
|
||||
"NV12" => (Chroma::C420, false, true),
|
||||
"I420" => (Chroma::C420, false, false),
|
||||
"I420A" => (Chroma::C420, true, false),
|
||||
"I422" => (Chroma::C422, false, false),
|
||||
"I422A" => (Chroma::C422, true, false),
|
||||
"I444" => (Chroma::C444, false, false),
|
||||
"I444A" => (Chroma::C444, true, false),
|
||||
_ => return Err("format"),
|
||||
};
|
||||
let rect = get(frame, "visibleRect");
|
||||
let w = integer(get(&rect, "width")).filter(|n| *n > 0).ok_or("geometry")?;
|
||||
let h = integer(get(&rect, "height")).filter(|n| *n > 0).ok_or("geometry")?;
|
||||
if integer(get(frame, "displayWidth")) != Some(w)
|
||||
|| integer(get(frame, "displayHeight")) != Some(h)
|
||||
|| get(frame, "rotation").as_f64().is_some_and(|r| r != 0.0)
|
||||
|| get(frame, "flip").as_bool() == Some(true)
|
||||
{
|
||||
return Err("geometry");
|
||||
}
|
||||
let color = color(frame, cfg.color).ok_or("color")?;
|
||||
let options = obj(&[("rect", rect)]);
|
||||
let bytes =
|
||||
integer(call(frame, "allocationSize", &[&options]).map_err(|_| "allocation")?).filter(|n| *n > 0 && *n <= MAX_COPY_BYTES).ok_or("allocation")?;
|
||||
let array: js_sys::Function = get(&js_sys::global(), "Uint8Array").dyn_into().map_err(|_| "allocation")?;
|
||||
let raw: js_sys::Uint8Array =
|
||||
js_sys::Reflect::construct(&array, &js_sys::Array::of1(&bytes.into())).and_then(|v| v.dyn_into()).map_err(|_| "allocation")?;
|
||||
let promise = call(frame, "copyTo", &[raw.as_ref(), &options]).map_err(|_| "copy")?.dyn_into::<js_sys::Promise>().map_err(|_| "copy")?;
|
||||
let layout = js_sys::Array::from(&JsFuture::from(promise).await.map_err(|_| "copy")?);
|
||||
let count = if interleaved {
|
||||
2
|
||||
} else if alpha {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
};
|
||||
if layout.length() != count {
|
||||
return Err("layout");
|
||||
}
|
||||
let (sx, sy) = chroma.shifts();
|
||||
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
|
||||
let y = plane(&raw, &layout.get(0), w, h)?;
|
||||
let (u, v) = if interleaved {
|
||||
let uv = plane(&raw, &layout.get(1), cw.checked_mul(2).ok_or("allocation")?, ch)?;
|
||||
let pairs = uv.as_chunks::<2>().0;
|
||||
let (mut u, mut v) = (Vec::new(), Vec::new());
|
||||
u.try_reserve_exact(pairs.len()).map_err(|_| "memory")?;
|
||||
v.try_reserve_exact(pairs.len()).map_err(|_| "memory")?;
|
||||
u.extend(pairs.iter().map(|p| p[0]));
|
||||
v.extend(pairs.iter().map(|p| p[1]));
|
||||
(u, v)
|
||||
} else {
|
||||
(plane(&raw, &layout.get(1), cw, ch)?, plane(&raw, &layout.get(2), cw, ch)?)
|
||||
};
|
||||
let alpha = if alpha { Some(Arc::new(plane(&raw, &layout.get(3), w, h)?)) } else { None };
|
||||
Ok((
|
||||
VideoFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
data: PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma, alpha },
|
||||
color,
|
||||
par: cfg.par,
|
||||
pts: Tick::ZERO,
|
||||
},
|
||||
bytes,
|
||||
))
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
//! Download spacing. It is pure, so it runs natively even though the rest of the crate is
|
||||
//! wasm32-only.
|
||||
|
||||
#[path = "../src/download_pacing.rs"]
|
||||
mod download_pacing;
|
||||
|
||||
use download_pacing::{GAP_MS, Pacer};
|
||||
|
||||
/// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst.
|
||||
#[test]
|
||||
fn burst_of_downloads_is_spaced_out() {
|
||||
let mut p = Pacer::default();
|
||||
let delays: Vec<f64> = (0..20).map(|_| p.reserve(1000.0)).collect();
|
||||
assert_eq!(delays[0], 0.0);
|
||||
for (i, d) in delays.iter().enumerate() {
|
||||
assert_eq!(*d, i as f64 * GAP_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/// A download after a quiet period is not delayed.
|
||||
#[test]
|
||||
fn download_after_a_pause_is_immediate() {
|
||||
let mut p = Pacer::default();
|
||||
assert_eq!(p.reserve(0.0), 0.0);
|
||||
assert_eq!(p.reserve(10_000.0), 0.0);
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
// Web import destinations: active bin tab, browser drop + navigation during pending reads.
|
||||
// Synthetic browser drop event exercises the production handler; physical OS drag/picker UI is outside this test.
|
||||
export async function checkImportBins({ evaluate, media, timeoutMs = 120000 }) {
|
||||
const report = {gates: {}, cases: []};
|
||||
await evaluate('filmcraft.execute("file.newProject", {name: "Import bins"})');
|
||||
const parent = (await evaluate('filmcraft.execute("file.newBin", {name: "Footage"})')).bin;
|
||||
const child = (await evaluate(`filmcraft.execute("file.newBin", {name: "Selects", parent: ${parent}})`)).bin;
|
||||
await evaluate(`(async () => {window.__importBinFixture = new Uint8Array(await (await fetch(${JSON.stringify(media)})).arrayBuffer());})()`);
|
||||
const setBin = bin => evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${JSON.stringify(bin)}, activeTab: null}})`);
|
||||
const importExpression = name => `filmcraft.importFiles([new File([window.__importBinFixture], ${JSON.stringify(name)}, {type: "video/mp4"})])`;
|
||||
const locate = async name => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const pollMs = 100;
|
||||
for (;;) {
|
||||
const project = await evaluate('filmcraft.execute("project.inspect")');
|
||||
const find = bin => {
|
||||
for (const entry of bin.children) {
|
||||
if (entry.item !== undefined && entry.name === name) return bin.id;
|
||||
if (entry.children) {const found = find(entry); if (found !== undefined) return found;}
|
||||
}
|
||||
};
|
||||
const found = find(project.root);
|
||||
if (found !== undefined) return found;
|
||||
if (Date.now() >= deadline) throw new Error(`Import never appeared: ${name}`);
|
||||
await new Promise(resolve => setTimeout(resolve, pollMs));
|
||||
}
|
||||
};
|
||||
const check = async (key, expected, name) => {
|
||||
const actual = await locate(name);
|
||||
report.cases.push({key, expected, actual, name}); report.gates[key] = actual === expected;
|
||||
};
|
||||
await evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${child}, activeTab: 0, tabs: [{bin: ${parent}}]}})`);
|
||||
await evaluate(importExpression("tab.mp4")); await check("activeTab", parent, "tab.mp4");
|
||||
await setBin(child);
|
||||
await evaluate(`(() => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([window.__importBinFixture], "drop.mp4", {type: "video/mp4"}));
|
||||
window.dispatchEvent(new DragEvent("drop", {dataTransfer: transfer, bubbles: true, cancelable: true}));
|
||||
})()`);
|
||||
await check("browserDrop", child, "drop.mp4");
|
||||
await setBin(child);
|
||||
await evaluate(`(() => {
|
||||
const original = Blob.prototype.arrayBuffer;
|
||||
let release;
|
||||
const pending = new Promise(resolve => {release = resolve;});
|
||||
const gate = window.__importBinGate = {hits: 0, release, original};
|
||||
Blob.prototype.arrayBuffer = function () {gate.hits++; return pending.then(() => original.call(this));};
|
||||
window.__pendingBinImport = ${importExpression("pending.mp4")};
|
||||
})()`);
|
||||
try {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const pollMs = 100;
|
||||
for (;;) {
|
||||
if (await evaluate("window.__importBinGate.hits > 0")) break;
|
||||
if (Date.now() >= deadline) throw new Error("Read-delay probe did not intercept any Blob reads");
|
||||
await new Promise(resolve => setTimeout(resolve, pollMs));
|
||||
}
|
||||
report.delayedReadHits = await evaluate("window.__importBinGate.hits");
|
||||
await setBin(parent);
|
||||
await evaluate("window.__importBinGate.release(); window.__pendingBinImport");
|
||||
await check("destinationCapturedBeforeRead", child, "pending.mp4");
|
||||
} finally {
|
||||
await evaluate("Blob.prototype.arrayBuffer = window.__importBinGate.original; window.__importBinGate.release(); delete window.__importBinGate; delete window.__pendingBinImport; delete window.__importBinFixture");
|
||||
}
|
||||
report.ok = Object.values(report.gates).every(Boolean);
|
||||
return report;
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
// #194 regression: same-name/same-size imports must retain independent sources across OPFS recovery.
|
||||
// evaluate(expression) awaits a browser expression; navigate(url) waits for FilmCraft readiness. Generated PCM16 mono WAVs only; no binary fixtures.
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function tone(frequency, seconds) {
|
||||
// RIFF/WAVE PCM16 mono: 44-byte header + 2 bytes/sample; fixture matches #194 (48 kHz, amplitude 12000).
|
||||
const rate = 48000, bytesPerSample = 2, samples = rate * seconds;
|
||||
const b = Buffer.alloc(44 + samples * bytesPerSample);
|
||||
b.write("RIFF", 0); b.writeUInt32LE(b.length - 8, 4); b.write("WAVEfmt ", 8);
|
||||
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
|
||||
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * bytesPerSample, 28);
|
||||
b.writeUInt16LE(bytesPerSample, 32); b.writeUInt16LE(16, 34); b.write("data", 36); b.writeUInt32LE(samples * bytesPerSample, 40);
|
||||
for (let i = 0; i < samples; i++) b.writeInt16LE(Math.round(12000 * Math.sin(2 * Math.PI * frequency * i / rate)), 44 + bytesPerSample * i);
|
||||
return b;
|
||||
}
|
||||
|
||||
export async function checkImportCollision({ evaluate, navigate, url, timeoutMs = 120000 }) {
|
||||
const fixtures = [tone(440, 2), tone(880, 2)];
|
||||
const hashes = fixtures.map(b => createHash("sha256").update(b).digest("hex"));
|
||||
const report = { hashes, fixtureBytes: fixtures.map(b => b.length), gates: {} };
|
||||
const hash = expression => evaluate(`(async () => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", ${expression})), b => b.toString(16).padStart(2, "0")).join(""))()`);
|
||||
const readHash = path => hash(`await filmcraft.readFile(${JSON.stringify(path)})`);
|
||||
const importFile = index => evaluate(`filmcraft.importFiles([new File([Uint8Array.from(atob(${JSON.stringify(fixtures[index].toString("base64"))}), c => c.charCodeAt(0))], "collision.wav", {type: "audio/wav"})])`);
|
||||
await evaluate('filmcraft.execute("file.newProject", {name: "Import collision"})');
|
||||
const a = await importFile(0); report.a = a;
|
||||
if (a.errors?.length || !a.items?.length) throw new Error("baseline import failed: " + JSON.stringify(a));
|
||||
report.initialHash = await readHash(a.paths[0]);
|
||||
report.gates.healthyInitial = report.initialHash === hashes[0];
|
||||
await evaluate(`filmcraft.execute("file.newSequence", {name: "Original A", fromItem: ${a.items[0]}})`);
|
||||
const b = await importFile(1); report.b = b;
|
||||
if (b.errors?.length || !b.items?.length) throw new Error("second import failed: " + JSON.stringify(b));
|
||||
report.afterBHash = await readHash(a.paths[0]);
|
||||
report.gates.distinctPaths = a.paths[0] !== b.paths[0];
|
||||
report.gates.originalPreserved = report.afterBHash === hashes[0];
|
||||
report.beforeProject = await evaluate('filmcraft.execute("project.inspect")');
|
||||
const revision = report.beforeProject.revision;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const persistencePollMs = 100;
|
||||
for (;;) {
|
||||
const snapshot = await evaluate(`(async () => {
|
||||
try {
|
||||
const dir = await (await navigator.storage.getDirectory()).getDirectoryHandle("recovery");
|
||||
const read = async name => JSON.parse(await (await (await dir.getFileHandle(name)).getFile()).text());
|
||||
return {meta: await read("meta.json"), project: await read("snapshot.fcproj")};
|
||||
} catch (e) {return {error: String(e)};}
|
||||
})()`);
|
||||
report.recoveryMeta = snapshot.meta;
|
||||
if (snapshot.meta?.dirty && snapshot.meta.revision === revision && JSON.stringify(snapshot.project).includes("Original A")) break;
|
||||
if (Date.now() >= deadline) throw new Error("Recovery persistence gate failed: " + JSON.stringify(snapshot));
|
||||
await new Promise(resolve => setTimeout(resolve, persistencePollMs));
|
||||
}
|
||||
// Media writes run separately from the snapshot queue; gate their actual bytes before navigation.
|
||||
for (;;) {
|
||||
report.opfs = await evaluate(`(async () => {
|
||||
const dir = await (await navigator.storage.getDirectory()).getDirectoryHandle("media");
|
||||
const files = [];
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
const bytes = await (await handle.getFile()).arrayBuffer();
|
||||
files.push({name, hash: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), b => b.toString(16).padStart(2, "0")).join("")});
|
||||
}
|
||||
return files;
|
||||
})()`);
|
||||
// Before the fix, A and B intentionally share the same path; qualify the last write there.
|
||||
const expected = new Map([[a.paths[0], hashes[0]], [b.paths[0], hashes[1]]]);
|
||||
if ([...expected].every(([path, digest]) => report.opfs.some(f => f.name === path.slice("/files/".length) && f.hash === digest))) break;
|
||||
if (Date.now() >= deadline) throw new Error("Imported media did not reach OPFS: " + JSON.stringify(report.opfs));
|
||||
await new Promise(resolve => setTimeout(resolve, persistencePollMs));
|
||||
}
|
||||
const recoveryUrl = new URL(url);
|
||||
recoveryUrl.searchParams.delete("fresh"); recoveryUrl.searchParams.delete("norecover");
|
||||
await navigate(recoveryUrl.href);
|
||||
report.recovered = await evaluate('filmcraft.execute("project.inspect")');
|
||||
report.afterReload = {a: await readHash(a.paths[0]), b: await readHash(b.paths[0])};
|
||||
report.gates.recoveredProject = JSON.stringify(report.recovered).includes("Original A");
|
||||
report.gates.recoveredSources = report.afterReload.a === hashes[0] && report.afterReload.b === hashes[1];
|
||||
report.ok = Object.values(report.gates).every(Boolean);
|
||||
return report;
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
//! The web recovery snapshot policy. It is pure, so it runs natively even though the rest of the
|
||||
//! crate is wasm32-only.
|
||||
|
||||
#[path = "../src/recovery_policy.rs"]
|
||||
mod recovery_policy;
|
||||
|
||||
use recovery_policy::{Action, INTERVAL_S, Policy};
|
||||
|
||||
/// #197: a snapshot write that fails is retried after the interval without another edit.
|
||||
#[test]
|
||||
fn failed_snapshot_write_is_retried_without_another_edit() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 3, 0), Action::WriteSnapshot(3));
|
||||
p.written(3, false, 0.1);
|
||||
// not immediately (no spinning on a failing disk)…
|
||||
assert!(matches!(p.tick(1.0, 3, 0), Action::WakeAfter(_)));
|
||||
// …but once the interval has passed, with the revision unchanged
|
||||
assert_eq!(p.tick(0.1 + INTERVAL_S, 3, 0), Action::WriteSnapshot(3));
|
||||
p.written(3, true, 5.2);
|
||||
assert_eq!(p.tick(20.0, 3, 0), Action::Nothing);
|
||||
}
|
||||
|
||||
/// A failure reported for an older revision doesn't make the newer one be written again.
|
||||
#[test]
|
||||
fn stale_failure_is_ignored() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
assert_eq!(p.tick(INTERVAL_S, 2, 0), Action::WriteSnapshot(2));
|
||||
p.written(1, false, INTERVAL_S);
|
||||
assert_eq!(p.tick(3.0 * INTERVAL_S, 2, 0), Action::Nothing);
|
||||
}
|
||||
|
||||
/// #193: a clean session (started with `?norecover` / `?fresh`, so the snapshot left by an
|
||||
/// earlier session was not reopened) never marks that snapshot clean.
|
||||
#[test]
|
||||
fn clean_start_leaves_an_unconsumed_snapshot_dirty() {
|
||||
let mut p = Policy::default();
|
||||
for t in 0..100 {
|
||||
assert_eq!(p.tick(t as f64, 0, 0), Action::Nothing);
|
||||
}
|
||||
}
|
||||
|
||||
/// The snapshot this session wrote is retired once the project is saved.
|
||||
#[test]
|
||||
fn saving_marks_own_snapshot_clean() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
p.written(1, true, 0.1);
|
||||
assert_eq!(p.tick(1.0, 1, 1), Action::WriteClean);
|
||||
assert_eq!(p.tick(1.1, 1, 1), Action::Nothing);
|
||||
p.cleaned(true, 1.2);
|
||||
assert_eq!(p.tick(30.0, 1, 1), Action::Nothing);
|
||||
}
|
||||
|
||||
/// A failed clean write is retried after the interval.
|
||||
#[test]
|
||||
fn failed_clean_write_is_retried() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
p.written(1, true, 0.1);
|
||||
assert_eq!(p.tick(1.0, 1, 1), Action::WriteClean);
|
||||
p.cleaned(false, 1.1);
|
||||
assert!(matches!(p.tick(2.0, 1, 1), Action::WakeAfter(_)));
|
||||
assert_eq!(p.tick(1.1 + INTERVAL_S, 1, 1), Action::WriteClean);
|
||||
}
|
||||
|
||||
/// An edit made while the clean write is in flight keeps the meta dirty after it lands.
|
||||
#[test]
|
||||
fn edit_during_clean_write_keeps_meta_owned() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
assert_eq!(p.tick(10.0, 1, 1), Action::WriteClean);
|
||||
assert_eq!(p.tick(20.0, 2, 1), Action::WriteSnapshot(2));
|
||||
p.cleaned(true, 20.1);
|
||||
// saved again: the newer snapshot must be retired too
|
||||
assert_eq!(p.tick(30.0, 2, 2), Action::WriteClean);
|
||||
}
|
||||
|
|
@ -13,8 +13,6 @@ import { spawn } from "node:child_process";
|
|||
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { checkImportBins } from "./import-bins.mjs";
|
||||
import { checkImportCollision } from "./import-collision.mjs";
|
||||
|
||||
const arg = (k, d) => {
|
||||
const i = process.argv.indexOf(`--${k}`);
|
||||
|
|
@ -164,19 +162,6 @@ try {
|
|||
await sleep(500);
|
||||
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
report.steps.modes = { screenshot: await shot("05-modes") };
|
||||
report.steps.importBins = await checkImportBins({evaluate: js, media});
|
||||
if (!report.steps.importBins.ok) throw new Error("import bin regression: " + JSON.stringify(report.steps.importBins.gates));
|
||||
report.steps.importCollision = await checkImportCollision({
|
||||
evaluate: js,
|
||||
url,
|
||||
navigate: async (nextUrl) => {
|
||||
await send("Page.navigate", { url: nextUrl });
|
||||
await until("!!(window.filmcraftLoad && (window.filmcraftLoad.readyMs || window.filmcraftLoad.error))", 120000);
|
||||
const load = await js("window.filmcraftLoad");
|
||||
if (load.error) throw new Error(load.error);
|
||||
},
|
||||
});
|
||||
if (!report.steps.importCollision.ok) throw new Error("import collision regression: " + JSON.stringify(report.steps.importCollision.gates));
|
||||
// still alive? (a panicked app never answers)
|
||||
await Promise.race([js("filmcraft.inspect().then(() => true)"), sleep(5000).then(() => { throw new Error("app stopped answering"); })]);
|
||||
const fatal = logs.filter((l) => /panicked at|RuntimeError|EXCEPTION/.test(l));
|
||||
|
|
|
|||
|
|
@ -14,16 +14,9 @@ workspace = true
|
|||
default = []
|
||||
# Speech-to-text (Text panel ▸ Transcribe) with Whisper on the CPU, and model downloads.
|
||||
whisper = ["filmcraft-engine/whisper", "filmcraft-engine/speech-download"]
|
||||
# Natural Text to Speech voices (Kokoro-82M on the CPU, downloaded on first use).
|
||||
neural-voices = ["filmcraft-engine/neural-voices"]
|
||||
|
||||
[dependencies]
|
||||
filmcraft-engine = { workspace = true }
|
||||
filmcraft-export = { workspace = true }
|
||||
filmcraft-project = { workspace = true }
|
||||
filmcraft-render = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
filmcraft-gpu = { workspace = true }
|
||||
filmcraft-ui-egui = { workspace = true }
|
||||
filmcraft-platform = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
|
|
@ -32,17 +25,7 @@ serde_json = { workspace = true }
|
|||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = "0.16"
|
||||
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
|
||||
arboard = "3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sys-locale = "0.3"
|
||||
# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft).
|
||||
open = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
glob = "0.3"
|
||||
# graphics.rs: enumerate the real adapters in a test.
|
||||
pollster = "0.4"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = "0.21"
|
||||
|
|
@ -52,11 +35,6 @@ objc2 = "0.6"
|
|||
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the
|
||||
# portal file dialogs (rfd) and accesskit already use.
|
||||
zbus = "5.19"
|
||||
|
||||
# Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows.
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
//! The system's light or dark appearance for Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with
|
||||
//! system, on Linux desktops where winit reports none (Wayland compositors send no theme).
|
||||
//!
|
||||
//! The XDG desktop portal is read once (`org.freedesktop.portal.Settings.Read` of
|
||||
//! `org.freedesktop.appearance` / `color-scheme`), then its `SettingChanged` signal is followed on a
|
||||
//! worker thread, which wakes the UI only when the value changes. There is no polling. Without a
|
||||
//! portal answer, `gsettings` runs once at start-up, by absolute path. Elsewhere the host returns no
|
||||
//! service and the UI uses what egui reports.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
decode(portal_code(value)?)
|
||||
}
|
||||
|
||||
/// The `u32` inside a portal reply. `Read` returns a variant, and some portals nest another
|
||||
/// variant inside it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
other => return u32::try_from(other).ok(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme`: 0 no preference, 1 prefer dark, 2 prefer light.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn decode(code: u32) -> Option<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot fallback for desktops without the settings portal: runs once, by absolute path.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn code_of(theme: Option<egui::Theme>) -> u8 {
|
||||
match theme {
|
||||
Some(egui::Theme::Dark) => 1,
|
||||
Some(egui::Theme::Light) => 2,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish initial and subsequent answers alike: the initial Read may finish after the UI's
|
||||
/// bounded startup wait, so it must also wake an already-created context.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn publish(value: &std::sync::atomic::AtomicU8, code: u8, repaint: impl FnOnce()) {
|
||||
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code {
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The host hook (`HostHooks::system_theme`), or `None` where egui's own report is used.
|
||||
pub fn service() -> Option<filmcraft_ui_egui::SystemThemeFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
let worker = move || {
|
||||
let proxy = portal_proxy();
|
||||
// Subscribe before Read so a change during startup cannot be missed.
|
||||
let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok());
|
||||
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
|
||||
publish(&worker_value, code_of(initial), || {
|
||||
if let Some(ctx) = worker_wake.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let _ = ready.send(());
|
||||
let Some(signals) = signals else { return };
|
||||
// Sleeps in the signal iterator: no polling.
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
let code = code_of(portal_code(changed).and_then(decode));
|
||||
publish(&worker_value, code, || {
|
||||
if let Some(ctx) = worker_wake.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
let spawned = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(worker)).is_err() {
|
||||
log::warn!("the system appearance watcher stopped; Sync with system keeps the last value");
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
return None;
|
||||
}
|
||||
// The first reading, so the first frame already shows the right theme (bounded wait).
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx: &egui::Context| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(Ordering::Relaxed)))
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn initial_and_signal_answers_repaint_only_on_changes() {
|
||||
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
|
||||
let repaints = AtomicUsize::new(0);
|
||||
let value = AtomicU8::new(0);
|
||||
let repaint = || {
|
||||
repaints.fetch_add(1, Ordering::Relaxed);
|
||||
};
|
||||
// A late initial Read takes the same publication path as later signals.
|
||||
super::publish(&value, 2, repaint);
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 1);
|
||||
super::publish(&value, 2, repaint);
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 1);
|
||||
super::publish(&value, 1, repaint);
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(value.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_and_gsettings_values_decode() {
|
||||
assert_eq!(super::decode(0), None);
|
||||
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::decode(2), Some(egui::Theme::Light));
|
||||
assert_eq!(super::decode(9), None);
|
||||
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
let plain = zbus::zvariant::OwnedValue::from(1u32);
|
||||
assert_eq!(super::portal_code(plain), Some(1));
|
||||
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
|
||||
assert_eq!(super::portal_code(wrong), None);
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
assert_eq!(super::parse_gtk_scheme(""), None);
|
||||
assert_eq!((super::code_of(None), super::code_of(Some(egui::Theme::Dark)), super::code_of(Some(egui::Theme::Light))), (0, 1, 2));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
//! 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...]
|
||||
|
|
@ -10,17 +7,12 @@ Usage: filmcraft [options] [project.fcproj | media files...]
|
|||
Options:
|
||||
--control <port> 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 <dir> folder for auto-save, crash recovery and logs
|
||||
(also: FILMCRAFT_DATA_DIR)
|
||||
--graphics <mode> 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
|
||||
";
|
||||
|
|
@ -34,10 +26,9 @@ pub enum Cli {
|
|||
}
|
||||
|
||||
/// Options of a normal start.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Launch {
|
||||
pub control_port: Option<u16>,
|
||||
pub control_input_only: bool,
|
||||
/// A project and / or media files to open.
|
||||
pub files: Vec<String>,
|
||||
pub demo: bool,
|
||||
|
|
@ -45,8 +36,6 @@ pub struct Launch {
|
|||
pub startup_flag: bool,
|
||||
pub recover: Option<bool>,
|
||||
pub data_dir: Option<std::path::PathBuf>,
|
||||
/// `--graphics <mode>`: start with this graphics mode and keep it (see `graphics.rs`).
|
||||
pub graphics: Option<crate::graphics::Mode>,
|
||||
}
|
||||
|
||||
/// Parse the arguments after the program name. `env_control_port` is the value of
|
||||
|
|
@ -56,49 +45,29 @@ 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`.
|
||||
///
|
||||
/// 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<String>, args: impl IntoIterator<Item = OsString>) -> Result<Cli, String> {
|
||||
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,
|
||||
};
|
||||
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
|
||||
let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
|
||||
let (mut help, mut version) = (false, false);
|
||||
let mut args = args.into_iter();
|
||||
while let Some(raw) = args.next() {
|
||||
let a = raw.to_string_lossy();
|
||||
match a.as_ref() {
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"-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(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())?);
|
||||
}
|
||||
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
|
||||
// everything after `--` is a file, whatever it looks like
|
||||
"--" => l.files.extend(args.by_ref().map(|f| f.to_string_lossy().into_owned())),
|
||||
"--" => l.files.extend(args.by_ref()),
|
||||
// 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.into_owned()),
|
||||
_ => l.files.push(a),
|
||||
}
|
||||
}
|
||||
if help {
|
||||
|
|
@ -112,51 +81,15 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = Os
|
|||
{
|
||||
l.control_port = Some(port.parse().map_err(|_| format!("FILMCRAFT_CONTROL_PORT needs a port number, got `{port}`"))?);
|
||||
}
|
||||
if l.control_input_only && l.control_port.is_none() {
|
||||
return Err("--control-input-only requires --control or FILMCRAFT_CONTROL_PORT".into());
|
||||
}
|
||||
Ok(Cli::Run(l))
|
||||
}
|
||||
|
||||
/// Whether a launch file is a FilmCraft project. The extension's case does not matter: Windows file
|
||||
/// associations and the Linux MIME glob start the app for `CUT.FCPROJ` too (#850).
|
||||
pub fn is_project(path: &str) -> 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<String>, Vec<String>) {
|
||||
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<crate::graphics::Mode, String> {
|
||||
use crate::graphics::{LADDER, Mode};
|
||||
let here = || LADDER.iter().map(|m| m.name()).collect::<Vec<_>>().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<Cli, String> {
|
||||
parse(env.map(str::to_string), args.iter().map(OsString::from))
|
||||
parse(env.map(str::to_string), args.iter().map(|a| a.to_string()))
|
||||
}
|
||||
|
||||
fn parse_args(args: &[&str]) -> Result<Cli, String> {
|
||||
|
|
@ -224,19 +157,7 @@ mod tests {
|
|||
#[test]
|
||||
fn known_options_and_files_parse_as_before() {
|
||||
let none = launch(&[]);
|
||||
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
|
||||
}
|
||||
);
|
||||
assert_eq!(none, Launch { control_port: None, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: 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)));
|
||||
|
|
@ -247,79 +168,4 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,20 @@
|
|||
//! 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, TryLockError};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
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<dyn FnMut(&mut [f32], usize) + Send>;
|
||||
|
||||
pub struct CpalOut {
|
||||
stream: Option<cpal::Stream>,
|
||||
/// The running playback's mixer, shared with the stream so a restarted stream continues it.
|
||||
fill: Option<Arc<Mutex<Fill>>>,
|
||||
played: Arc<AtomicU64>,
|
||||
health: Arc<Health>,
|
||||
watchdog: Watchdog,
|
||||
/// Time base for the watchdog.
|
||||
epoch: Instant,
|
||||
rate: u32,
|
||||
channels: u16,
|
||||
hw: AudioHardwarePrefs,
|
||||
/// Sequence sample rate for "Attempt to force hardware to document sample rate".
|
||||
document_rate: Option<u32>,
|
||||
/// Status-bar note: the host's default device could not be opened and a fallback device is
|
||||
/// in use, or no output device was found (playing without sound).
|
||||
note: Option<Note>,
|
||||
/// The fallback picked when the default device could not be opened, as (host, device) names,
|
||||
/// so later lookups (`devices`, `configure`, `start`) reuse it instead of probing every PCM.
|
||||
fallback: Mutex<Option<(String, String)>>,
|
||||
}
|
||||
|
||||
/// A hardware note for the status bar, translated when it is shown.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Note {
|
||||
/// The default output device cannot be opened; this named device is used instead.
|
||||
Fallback(String),
|
||||
/// No output device at all: playback runs without sound.
|
||||
NoDevice,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
fn text(&self) -> String {
|
||||
use filmcraft_ui_egui::i18n::{fmt, t};
|
||||
match self {
|
||||
Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]),
|
||||
Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host named in the settings (empty or unknown = the default host).
|
||||
|
|
@ -71,151 +28,48 @@ fn host(name: &str) -> cpal::Host {
|
|||
cpal::default_host()
|
||||
}
|
||||
|
||||
/// The output device the settings ask for, with a status-bar note when a fallback is in use.
|
||||
struct DeviceChoice {
|
||||
device: cpal::Device,
|
||||
/// The host's default device could not be opened: this device is the fallback.
|
||||
fallback: Option<Note>,
|
||||
}
|
||||
|
||||
/// Fallback order when the default device cannot be opened: the sound-server bridges first
|
||||
/// (`pipewire`, `pulse`: they share the card with every other app), then other named PCMs, and
|
||||
/// raw `hw:`/`plughw:` devices last (opening one takes the card exclusively and silences the
|
||||
/// rest of the desktop).
|
||||
fn fallback_rank(name: &str) -> u8 {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n == "pipewire" || n.starts_with("pipewire:") {
|
||||
0
|
||||
} else if n == "pulse" || n.starts_with("pulse:") {
|
||||
1
|
||||
} else if n.starts_with("hw:") || n.starts_with("plughw:") {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// The output device named in the settings (empty or missing = the host's default). When the
|
||||
/// default cannot be opened — a broken ALSA `default` (missing `99-pipewire-default.conf`,
|
||||
/// #23/#106) — a fallback that can, so the app is not silently mute. The fallback list is probed
|
||||
/// only after the default fails, in [`fallback_rank`] order, and the pick is cached in `cache`.
|
||||
fn output_device(h: &cpal::Host, name: &str, cache: &Mutex<Option<(String, String)>>) -> Option<DeviceChoice> {
|
||||
/// The output device named in the settings (empty or missing = the host's default).
|
||||
fn output_device(h: &cpal::Host, name: &str) -> Option<cpal::Device> {
|
||||
if !name.is_empty()
|
||||
&& let Ok(mut devs) = h.output_devices()
|
||||
&& let Some(d) = devs.find(|d| d.name().is_ok_and(|n| n == name))
|
||||
{
|
||||
return Some(DeviceChoice { device: d, fallback: None });
|
||||
return Some(d);
|
||||
}
|
||||
if let Some(d) = h.default_output_device()
|
||||
&& d.default_output_config().is_ok()
|
||||
{
|
||||
return Some(DeviceChoice { device: d, fallback: None });
|
||||
}
|
||||
let host_name = h.id().name().to_string();
|
||||
let mut cache = cache.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut devs: Vec<(String, cpal::Device)> = h.output_devices().ok()?.filter_map(|d| d.name().ok().map(|n| (n, d))).collect();
|
||||
if let Some((cached_host, cached)) = cache.as_ref()
|
||||
&& *cached_host == host_name
|
||||
&& let Some(i) = devs.iter().position(|(n, _)| n == cached)
|
||||
{
|
||||
let (found, d) = devs.swap_remove(i);
|
||||
return Some(DeviceChoice { device: d, fallback: Some(Note::Fallback(found)) });
|
||||
}
|
||||
devs.sort_by_key(|(n, _)| fallback_rank(n));
|
||||
let (found, d) = devs.into_iter().find(|(_, d)| d.default_output_config().is_ok())?;
|
||||
log::warn!("the default audio output device cannot be opened; falling back to '{found}'");
|
||||
*cache = Some((host_name, found.clone()));
|
||||
Some(DeviceChoice { device: d, fallback: Some(Note::Fallback(found)) })
|
||||
h.default_output_device()
|
||||
}
|
||||
|
||||
impl CpalOut {
|
||||
pub fn new() -> Self {
|
||||
pub fn new() -> Option<Self> {
|
||||
let host = cpal::default_host();
|
||||
let fallback = Mutex::new(None);
|
||||
let choice = output_device(&host, "", &fallback);
|
||||
let cfg = choice.as_ref().and_then(|c| c.device.default_output_config().ok());
|
||||
let note = match choice {
|
||||
Some(c) => c.fallback,
|
||||
None => {
|
||||
log::warn!("no audio output device found");
|
||||
Some(Note::NoDevice)
|
||||
}
|
||||
};
|
||||
Self {
|
||||
stream: None,
|
||||
fill: None,
|
||||
played: Arc::new(AtomicU64::new(0)),
|
||||
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(),
|
||||
document_rate: None,
|
||||
note,
|
||||
fallback,
|
||||
}
|
||||
let dev = host.default_output_device()?;
|
||||
let cfg = dev.default_output_config().ok()?;
|
||||
Some(Self { stream: None, played: Arc::new(AtomicU64::new(0)), rate: cfg.sample_rate().0, hw: AudioHardwarePrefs::default(), document_rate: None })
|
||||
}
|
||||
|
||||
/// The stream configuration the settings ask for, falling back to the device default.
|
||||
fn config(&self, dev: &cpal::Device) -> Result<cpal::SupportedStreamConfig, String> {
|
||||
let default = dev.default_output_config().map_err(|e| e.to_string())?;
|
||||
let want = if self.hw.force_document_rate { self.document_rate.unwrap_or(self.hw.sample_rate) } else { self.hw.sample_rate };
|
||||
let exact = dev.supported_output_configs().ok().and_then(|it| {
|
||||
it.filter(|c| c.channels() > 0 && (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&want))
|
||||
.max_by_key(|c| {
|
||||
(c.channels() == default.channels(), c.sample_format() == cpal::SampleFormat::F32, c.sample_format() == default.sample_format())
|
||||
})
|
||||
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(want)))
|
||||
let exact = dev.supported_output_configs().ok().and_then(|mut it| {
|
||||
it.find(|c| {
|
||||
c.sample_format() == cpal::SampleFormat::F32
|
||||
&& c.channels() == default.channels()
|
||||
&& (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&want)
|
||||
})
|
||||
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(want)))
|
||||
});
|
||||
Ok(exact.unwrap_or(default))
|
||||
}
|
||||
}
|
||||
|
||||
fn converted_stream<T>(&self, dev: &cpal::Device, config: &cpal::StreamConfig, fill: Arc<Mutex<Fill>>) -> Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
T: cpal::SizedSample + cpal::FromSample<f32>,
|
||||
{
|
||||
let channels = usize::from(config.channels);
|
||||
let played = self.played.clone();
|
||||
let health = self.health.clone();
|
||||
let error_health = health.clone();
|
||||
let mut scratch = Vec::new();
|
||||
dev.build_output_stream(
|
||||
config,
|
||||
move |buf: &mut [T], _| {
|
||||
// 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 !health.failed.swap(true, Ordering::SeqCst) {
|
||||
eprintln!("filmcraft: unable to allocate the audio conversion buffer");
|
||||
}
|
||||
return;
|
||||
}
|
||||
scratch.resize(buf.len(), 0.0);
|
||||
render(&fill, &mut scratch, channels, &played, &health);
|
||||
convert_samples(&scratch, buf);
|
||||
},
|
||||
move |e| stream_error(&error_health, e),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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<Mutex<Fill>>, expect: Option<(u32, u16)>) -> Result<(), String> {
|
||||
impl AudioOut for CpalOut {
|
||||
fn start(&mut self, mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
|
||||
self.stop();
|
||||
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;
|
||||
let dev = output_device(&host, &self.hw.default_output).ok_or("no output device")?;
|
||||
let cfg = self.config(&dev)?;
|
||||
let channels = cfg.channels() as usize;
|
||||
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)
|
||||
|
|
@ -223,216 +77,40 @@ impl CpalOut {
|
|||
config.buffer_size = cpal::BufferSize::Fixed(self.hw.buffer_size);
|
||||
}
|
||||
self.rate = cfg.sample_rate().0;
|
||||
self.channels = cfg.channels();
|
||||
self.played.store(0, Ordering::SeqCst);
|
||||
let played = self.played.clone();
|
||||
let err = |e| eprintln!("filmcraft: audio stream error: {e}");
|
||||
let stream = match cfg.sample_format() {
|
||||
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::<i8>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I16 => self.converted_stream::<i16>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I24 => self.converted_stream::<cpal::I24>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I32 => self.converted_stream::<i32>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I64 => self.converted_stream::<i64>(&dev, &config, fill),
|
||||
cpal::SampleFormat::U8 => self.converted_stream::<u8>(&dev, &config, fill),
|
||||
cpal::SampleFormat::U16 => self.converted_stream::<u16>(&dev, &config, fill),
|
||||
cpal::SampleFormat::U32 => self.converted_stream::<u32>(&dev, &config, fill),
|
||||
cpal::SampleFormat::U64 => self.converted_stream::<u64>(&dev, &config, fill),
|
||||
cpal::SampleFormat::F64 => self.converted_stream::<f64>(&dev, &config, fill),
|
||||
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);
|
||||
},
|
||||
err,
|
||||
None,
|
||||
),
|
||||
other => return Err(format!("unsupported sample format {other:?}")),
|
||||
}
|
||||
.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<Duration>,
|
||||
}
|
||||
|
||||
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<Fill>, 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<T: cpal::Sample + cpal::FromSample<f32>>(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<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
|
||||
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
|
||||
}
|
||||
fn channels(&self) -> usize {
|
||||
self.channels as usize
|
||||
}
|
||||
fn played_frames(&self) -> Option<u64> {
|
||||
self.stream.as_ref().filter(|_| !self.health.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst))
|
||||
self.stream.as_ref().map(|_| self.played.load(Ordering::SeqCst))
|
||||
}
|
||||
fn devices(&self) -> AudioDevices {
|
||||
let h = host(&self.hw.device_class);
|
||||
let outputs = h.output_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default();
|
||||
let inputs = h.input_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default();
|
||||
let output_channels =
|
||||
output_device(&h, &self.hw.default_output, &self.fallback).and_then(|c| self.config(&c.device).ok()).map(|cfg| cfg.channels()).unwrap_or(0);
|
||||
let output_channels = output_device(&h, &self.hw.default_output).and_then(|d| self.config(&d).ok()).map(|c| c.channels()).unwrap_or(0);
|
||||
AudioDevices { hosts: cpal::available_hosts().iter().map(|h| h.name().to_string()).collect(), inputs, outputs, output_channels }
|
||||
}
|
||||
fn configure(&mut self, hw: &AudioHardwarePrefs, document_rate: Option<u32>) {
|
||||
|
|
@ -440,141 +118,10 @@ impl AudioOut for CpalOut {
|
|||
self.document_rate = document_rate;
|
||||
// the rate playback mixes at must be known before `start`
|
||||
let host = host(&self.hw.device_class);
|
||||
if let Some(choice) = output_device(&host, &self.hw.default_output, &self.fallback)
|
||||
&& let Ok(cfg) = self.config(&choice.device)
|
||||
if let Some(dev) = output_device(&host, &self.hw.default_output)
|
||||
&& let Ok(cfg) = self.config(&dev)
|
||||
{
|
||||
self.rate = cfg.sample_rate().0;
|
||||
self.channels = cfg.channels();
|
||||
self.note = choice.fallback;
|
||||
}
|
||||
}
|
||||
fn note(&self) -> Option<String> {
|
||||
self.note.as_ref().map(Note::text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cpal::Sample;
|
||||
|
||||
#[test]
|
||||
fn fallback_prefers_sound_servers_over_raw_hardware() {
|
||||
let mut names = vec!["plughw:CARD=PCH,DEV=0", "hw:CARD=PCH,DEV=0", "sysdefault:CARD=PCH", "pulse", "pipewire"];
|
||||
names.sort_by_key(|n| fallback_rank(n));
|
||||
assert_eq!(names, ["pipewire", "pulse", "sysdefault:CARD=PCH", "plughw:CARD=PCH,DEV=0", "hw:CARD=PCH,DEV=0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_name_the_fallback_device() {
|
||||
assert!(Note::Fallback("pipewire".into()).text().contains("'pipewire'"));
|
||||
assert!(!Note::NoDevice.text().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_conversion_clips_and_preserves_interleaved_channels() {
|
||||
let mut signed = [0_i16; 8];
|
||||
convert_samples(&[-2.0, 2.0, -0.5, 0.5, 0.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY], &mut signed);
|
||||
assert_eq!(signed, [i16::MIN, i16::MAX, -16_384, 16_384, 0, 0, 0, 0]);
|
||||
let mut unsigned = [0_u16; 3];
|
||||
convert_samples(&[-1.0, 0.0, 1.0], &mut unsigned);
|
||||
assert_eq!(unsigned, [0, 32_768, u16::MAX]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_scale_24_bit_samples_remain_representable() {
|
||||
let mut samples = [cpal::I24::EQUILIBRIUM; 3];
|
||||
convert_samples(&[-1.0, 0.0, 1.0], &mut samples);
|
||||
assert_eq!(samples.map(|s| s.inner()), [-8_388_608, 0, 8_388_607]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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, "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<Fill> = 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<Fill> = 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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,11 @@ pub struct CpalIn {
|
|||
host: String,
|
||||
buf: Arc<Mutex<Vec<Vec<f32>>>>,
|
||||
stop: Option<mpsc::Sender<()>>,
|
||||
worker: Option<std::thread::JoinHandle<()>>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl CpalIn {
|
||||
pub fn new(host: &str) -> Self {
|
||||
Self { host: host.to_string(), buf: Arc::default(), stop: None, worker: None, error: Arc::default() }
|
||||
Self { host: host.to_string(), buf: Arc::default(), stop: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,63 +46,7 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn report_error(error: &Mutex<Option<String>>, message: String) {
|
||||
let mut error = lock(error);
|
||||
if error.is_none() {
|
||||
eprintln!("filmcraft: audio input error: {message}");
|
||||
*error = Some(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn append<T: cpal::Sample>(data: &[T], channels: usize, buf: &Mutex<Vec<Vec<f32>>>, error: &Mutex<Option<String>>)
|
||||
where
|
||||
f32: cpal::FromSample<T>,
|
||||
{
|
||||
if lock(error).is_some() {
|
||||
return;
|
||||
}
|
||||
let mut b = lock(buf);
|
||||
if channels == 0 || channels > 256 || data.len() > 1_048_576 {
|
||||
report_error(error, "invalid input device buffer".into());
|
||||
return;
|
||||
}
|
||||
if b.len() != channels {
|
||||
*b = vec![Vec::new(); channels];
|
||||
}
|
||||
// The recorder drains at stop. Bound accumulated samples and report exhaustion instead of
|
||||
// allowing a long take or a broken/unpaced driver to exhaust the application heap.
|
||||
let samples = b.first().map_or(0, Vec::len).saturating_mul(channels).saturating_add(data.len());
|
||||
let frames = data.len() / channels;
|
||||
if samples > 268_435_456 || b.iter_mut().any(|c| c.try_reserve(frames).is_err()) {
|
||||
report_error(error, "voice-over input buffer is full; recording failed".into());
|
||||
return;
|
||||
}
|
||||
for frame in data.chunks_exact(channels) {
|
||||
for (channel, sample) in b.iter_mut().zip(frame) {
|
||||
let sample = sample.to_sample::<f32>();
|
||||
channel.push(if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn input_stream<T: cpal::SizedSample>(
|
||||
dev: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
buf: Arc<Mutex<Vec<Vec<f32>>>>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
) -> Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
f32: cpal::FromSample<T>,
|
||||
{
|
||||
let channels = usize::from(config.channels);
|
||||
let stream_error = error.clone();
|
||||
dev.build_input_stream(config, move |data: &[T], _| append(data, channels, &buf, &error), move |e| report_error(&stream_error, e.to_string()), None)
|
||||
}
|
||||
|
||||
impl AudioInput for CpalIn {
|
||||
fn configure_host(&mut self, name: &str) {
|
||||
self.host = name.to_string();
|
||||
}
|
||||
fn devices(&self) -> Vec<String> {
|
||||
host(&self.host).input_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
|
@ -114,77 +56,68 @@ impl AudioInput for CpalIn {
|
|||
fn start(&mut self, name: &str, sample_rate: u32) -> Result<InputFormat, String> {
|
||||
self.stop();
|
||||
lock(&self.buf).clear();
|
||||
*lock(&self.error) = None;
|
||||
let (host_name, name) = (self.host.clone(), name.to_string());
|
||||
let buf = self.buf.clone();
|
||||
let error = self.error.clone();
|
||||
let (fmt_tx, fmt_rx) = mpsc::channel::<Result<InputFormat, String>>();
|
||||
let (stop_tx, stop_rx) = mpsc::channel::<()>();
|
||||
self.worker = Some(
|
||||
std::thread::Builder::new()
|
||||
.name("voice-over input".into())
|
||||
.spawn(move || {
|
||||
let open = || -> Result<(cpal::Stream, InputFormat), String> {
|
||||
let h = host(&host_name);
|
||||
let dev = device(&h, &name).ok_or("no input device")?;
|
||||
let default = dev.default_input_config().map_err(|e| e.to_string())?;
|
||||
// Prefer f32 at the sequence rate, retaining integer-only input devices.
|
||||
let cfg = dev
|
||||
.supported_input_configs()
|
||||
.ok()
|
||||
.and_then(|it| {
|
||||
it.filter(|c| c.channels() > 0 && (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&sample_rate))
|
||||
.max_by_key(|c| (c.channels() == default.channels(), c.sample_format() == cpal::SampleFormat::F32))
|
||||
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(sample_rate)))
|
||||
})
|
||||
.unwrap_or(default);
|
||||
if cfg.channels() == 0 || cfg.channels() > 256 || cfg.sample_rate().0 == 0 {
|
||||
return Err("the input device returned an invalid format".into());
|
||||
}
|
||||
let fmt = InputFormat { sample_rate: cfg.sample_rate().0, channels: cfg.channels() };
|
||||
let format = cfg.sample_format();
|
||||
let config = cfg.into();
|
||||
let stream = match format {
|
||||
cpal::SampleFormat::I8 => input_stream::<i8>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::I16 => input_stream::<i16>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::I24 => input_stream::<cpal::I24>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::I32 => input_stream::<i32>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::I64 => input_stream::<i64>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::U8 => input_stream::<u8>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::U16 => input_stream::<u16>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::U32 => input_stream::<u32>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::U64 => input_stream::<u64>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::F32 => input_stream::<f32>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::F64 => input_stream::<f64>(&dev, &config, buf.clone(), error.clone()),
|
||||
other => return Err(format!("unsupported input sample format {other:?}")),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
Ok((stream, fmt))
|
||||
};
|
||||
let opened = std::panic::catch_unwind(std::panic::AssertUnwindSafe(open)).unwrap_or_else(|_| Err("audio input worker panicked".into()));
|
||||
match opened {
|
||||
Ok((stream, fmt)) => {
|
||||
let _ = fmt_tx.send(Ok(fmt));
|
||||
// keep the stream alive until stop (or the sender is dropped)
|
||||
let _ = stop_rx.recv();
|
||||
drop(stream);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fmt_tx.send(Err(e));
|
||||
}
|
||||
std::thread::Builder::new()
|
||||
.name("voice-over input".into())
|
||||
.spawn(move || {
|
||||
let open = || -> Result<(cpal::Stream, InputFormat), String> {
|
||||
let h = host(&host_name);
|
||||
let dev = device(&h, &name).ok_or("no input device")?;
|
||||
let default = dev.default_input_config().map_err(|e| e.to_string())?;
|
||||
// the sequence rate when the device offers it in f32, else the device default
|
||||
let cfg = dev
|
||||
.supported_input_configs()
|
||||
.ok()
|
||||
.and_then(|mut it| {
|
||||
it.find(|c| c.sample_format() == cpal::SampleFormat::F32 && (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&sample_rate))
|
||||
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(sample_rate)))
|
||||
})
|
||||
.unwrap_or(default);
|
||||
if cfg.sample_format() != cpal::SampleFormat::F32 {
|
||||
return Err(format!("unsupported input sample format {:?}", cfg.sample_format()));
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
let ch = cfg.channels() as usize;
|
||||
let fmt = InputFormat { sample_rate: cfg.sample_rate().0, channels: cfg.channels() };
|
||||
let stream = dev
|
||||
.build_input_stream(
|
||||
&cfg.into(),
|
||||
move |data: &[f32], _| {
|
||||
let mut b = lock(&buf);
|
||||
if b.len() != ch {
|
||||
*b = vec![Vec::new(); ch];
|
||||
}
|
||||
for frame in data.chunks_exact(ch.max(1)) {
|
||||
for (c, x) in frame.iter().enumerate() {
|
||||
b[c].push(*x);
|
||||
}
|
||||
}
|
||||
},
|
||||
|e| eprintln!("filmcraft: audio input error: {e}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
Ok((stream, fmt))
|
||||
};
|
||||
match open() {
|
||||
Ok((stream, fmt)) => {
|
||||
let _ = fmt_tx.send(Ok(fmt));
|
||||
// keep the stream alive until stop (or the sender is dropped)
|
||||
let _ = stop_rx.recv();
|
||||
drop(stream);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fmt_tx.send(Err(e));
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let fmt = fmt_rx.recv().map_err(|e| e.to_string())??;
|
||||
self.stop = Some(stop_tx);
|
||||
match fmt_rx.recv().map_err(|e| e.to_string()).and_then(|f| f) {
|
||||
Ok(fmt) => Ok(fmt),
|
||||
Err(e) => {
|
||||
self.stop();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
Ok(fmt)
|
||||
}
|
||||
fn read(&mut self, frames: usize) -> Vec<Vec<f32>> {
|
||||
let mut b = lock(&self.buf);
|
||||
|
|
@ -198,56 +131,9 @@ impl AudioInput for CpalIn {
|
|||
fn discard(&mut self) {
|
||||
lock(&self.buf).iter_mut().for_each(Vec::clear);
|
||||
}
|
||||
fn error(&self) -> Option<String> {
|
||||
lock(&self.error).clone()
|
||||
}
|
||||
fn stop(&mut self) {
|
||||
if let Some(tx) = self.stop.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(worker) = self.worker.take()
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
report_error(&self.error, "audio input worker panicked".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CpalIn {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signed_and_unsigned_input_is_deinterleaved_to_float() {
|
||||
let buf = Mutex::new(Vec::new());
|
||||
let error = Mutex::new(None);
|
||||
append(&[i16::MIN, i16::MAX, 0, 16384], 2, &buf, &error);
|
||||
let data = lock(&buf);
|
||||
assert_eq!(data[0], [-1.0, 0.0]);
|
||||
assert!((data[1][0] - 1.0).abs() < 0.0001);
|
||||
assert_eq!(data[1][1], 0.5);
|
||||
drop(data);
|
||||
lock(&buf).clear();
|
||||
append(&[0_u16, 32768, 65535, 32768], 2, &buf, &error);
|
||||
let data = lock(&buf);
|
||||
assert_eq!(data[1], [0.0, 0.0]);
|
||||
assert_eq!(data[0][0], -1.0);
|
||||
assert!(lock(&error).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_is_reported_without_panicking_or_appending() {
|
||||
let buf = Mutex::new(Vec::new());
|
||||
let error = Mutex::new(None);
|
||||
append(&[0.0_f32], 0, &buf, &error);
|
||||
assert!(lock(&error).is_some());
|
||||
append(&[1.0_f32], 1, &buf, &error);
|
||||
assert!(lock(&buf).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,13 @@
|
|||
//! Localhost JSON-lines control server (one request per line, one reply per line). Loopback only.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use filmcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
|
|
@ -32,257 +18,44 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
|||
}
|
||||
};
|
||||
eprintln!("filmcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
// The slot is given back when the guard drops: after `serve` returns, if it panics, or if
|
||||
// the thread can't be started (the closure, and with it the guard, is dropped).
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let spawned = std::thread::Builder::new().name("control-conn".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
eprintln!("filmcraft: control server could not start a connection thread: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One counted connection; decrements the open-connection count when dropped.
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Slot {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST (issue #119): the request line is rejected and the
|
||||
// connection closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:9876\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.openDemoProject\",\"params\":{}}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused() {
|
||||
// One byte over the limit, then a valid request: neither runs.
|
||||
let mut input = vec![b'x'; MAX_LINE + 1];
|
||||
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
|
||||
let (r, calls) = run(&input);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,329 +0,0 @@
|
|||
//! Dev-time Linux desktop entry: the shell icon fallback.
|
||||
//!
|
||||
//! The Linux shell (Wayland *and* X11) resolves the window to a `.desktop` file by app id (set in
|
||||
//! `main.rs` via `eframe`'s `with_app_id`) and takes the taskbar / window-list icon from its
|
||||
//! `Icon=` entry in the hicolor theme. Packaging installs both
|
||||
//! (`packaging/linux/ai.storyteller.filmcraft.desktop` and the hicolor PNGs) — but a source-tree
|
||||
//! run has neither, so KDE/GNOME fall back to the generic Wayland icon. The fix is to ensure a
|
||||
//! user-level entry for those runs: when no entry for the app id exists in any XDG data dir,
|
||||
//! install one (plus the hicolor icon PNGs, read from this checkout's `assets/app-icon`, so
|
||||
//! nothing is embedded in the binary) into `$XDG_DATA_HOME`/`~/.local/share`. An existing
|
||||
//! packaged entry is never overridden; the user-owned dev copy is rewritten when it points at
|
||||
//! another binary (a dev build moves between checkouts). The shell picks the icon up at the *next* app start;
|
||||
//! `update-desktop-database`/`kbuildsycoca` is not needed for icon lookup.
|
||||
//!
|
||||
//! Opt-in only: nothing is written unless `FILMCRAFT_DEV_DESKTOP_ENTRY=1` is set, so ordinary
|
||||
//! `cargo run`s (agents, worktrees, CI) never touch the user's profile. Packaged runs are
|
||||
//! excluded even then — AppImage (`APPIMAGE`) mounts at a fresh `/tmp/.mount_*` each launch,
|
||||
//! so its `Exec` path changes every time and it ships its own entry, and a Flatpak
|
||||
//! (`FLATPAK_ID`) installs its entry through the manifest. `packaging/linux/install.sh` writes the packaged
|
||||
//! `Exec=` the same two-layer way as here; keep the two in sync.
|
||||
//!
|
||||
//! Every failure is logged and skipped — a missing dev icon must never stop the app
|
||||
//! ([`AGENTS.md`](/AGENTS.md) §0 "never crash").
|
||||
|
||||
/// Install the user-level desktop entry and icons for a source-tree run (Linux).
|
||||
pub fn ensure_dev_desktop_entry() {
|
||||
// Packaged and sandboxed runs never write into the user's profile: an AppImage mounts at a
|
||||
// fresh /tmp/.mount_* every launch (the Exec path changes each time) and a Flatpak installs
|
||||
// its own entry.
|
||||
if std::env::var_os("APPIMAGE").is_some() || std::env::var_os("FLATPAK_ID").is_some() {
|
||||
return;
|
||||
}
|
||||
// Opt-in only: a plain `cargo run` (agents, worktrees, other checkouts) must not write into
|
||||
// the user's profile, and a packaged release never does (packages and install.sh install
|
||||
// the real entry).
|
||||
if std::env::var_os("FILMCRAFT_DEV_DESKTOP_ENTRY").is_none_or(|v| v != "1") {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = ensure() {
|
||||
log::warn!("desktop entry (taskbar icon) not installed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Install missing files; `Ok` whether anything was written or an entry already existed.
|
||||
fn ensure() -> Result<(), DevIconError> {
|
||||
let file_name = format!("{}.desktop", crate::APP_ID);
|
||||
let user_dir = user_data_dir()?;
|
||||
let exe = std::env::current_exe().map_err(DevIconError::Io)?;
|
||||
// Not `to_string_lossy`: a substituted U+FFFD would install an `Exec` that can never start
|
||||
// the app, and the same lossy string would match on every later run, so the broken entry
|
||||
// would never be rewritten or warned about.
|
||||
let exe = exe.to_str().ok_or(DevIconError::BadExecPath)?;
|
||||
if exe.contains('=') || exe.contains('%') || exe.chars().any(|c| c.is_control()) {
|
||||
// A desktop entry cannot carry these (install.sh rejects them for the same reason):
|
||||
// '=' separates key and value, '%' introduces a field code, control characters are
|
||||
// not valid in the string layer.
|
||||
return Err(DevIconError::BadExecPath);
|
||||
}
|
||||
for dir in search_dirs(&user_dir) {
|
||||
let candidate = dir.join("applications").join(&file_name);
|
||||
if !candidate.is_file() {
|
||||
continue;
|
||||
}
|
||||
// A packaged entry (any system dir) already gives the shell its icon; only our own
|
||||
// user-level copy is kept fresh.
|
||||
if dir != user_dir {
|
||||
return Ok(());
|
||||
}
|
||||
let body = std::fs::read_to_string(&candidate).map_err(DevIconError::Io)?;
|
||||
if desktop_file_exec_is_ours(&body, exe) {
|
||||
return Ok(());
|
||||
}
|
||||
break; // a dev entry from another checkout: fall through and rewrite it
|
||||
}
|
||||
for size in ICON_SIZES {
|
||||
let rel = std::path::Path::new("hicolor").join(format!("{size}x{size}")).join("apps").join(format!("{}.png", crate::APP_ID));
|
||||
let dest = user_dir.join("icons").join(&rel);
|
||||
if !dest.is_file()
|
||||
&& let Some(parent) = dest.parent()
|
||||
{
|
||||
// The checkout this binary was built from; a missing icon only costs the icon.
|
||||
let src = std::path::Path::new(ICON_DIR).join(&rel);
|
||||
if !src.is_file() {
|
||||
log::warn!("dev desktop entry: icon {} not found; skipping it", src.display());
|
||||
continue;
|
||||
}
|
||||
std::fs::create_dir_all(parent).map_err(DevIconError::Io)?;
|
||||
std::fs::copy(&src, &dest).map_err(DevIconError::Io)?;
|
||||
}
|
||||
}
|
||||
let applications = user_dir.join("applications");
|
||||
std::fs::create_dir_all(&applications).map_err(DevIconError::Io)?;
|
||||
let body = desktop_file_content(crate::APP_ID, "FilmCraft", exe);
|
||||
std::fs::write(applications.join(&file_name), body).map_err(DevIconError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The hicolor icon sizes installed for the taskbar (a subset large enough for panel and topbar;
|
||||
/// the shell picks the nearest size, so 256 and 512 cover both).
|
||||
const ICON_SIZES: [u16; 2] = [256, 512];
|
||||
|
||||
/// The app icons in the source tree this binary was built from (a path, not embedded bytes:
|
||||
/// the entry is a dev-checkout convenience and release binaries must not carry the PNGs).
|
||||
const ICON_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/app-icon");
|
||||
|
||||
/// The user data dir we install into: `$XDG_DATA_HOME`, else `$HOME/.local/share` (XDG base
|
||||
/// directory spec). Untrusted environment: an empty or relative `XDG_DATA_HOME` means the
|
||||
/// default, and a missing or relative `HOME` is an error — never a guess that would write
|
||||
/// into the working directory.
|
||||
fn user_data_dir() -> Result<std::path::PathBuf, DevIconError> {
|
||||
if let Some(v) = std::env::var_os("XDG_DATA_HOME")
|
||||
&& !v.is_empty()
|
||||
&& std::path::Path::new(&v).is_absolute()
|
||||
{
|
||||
return Ok(std::path::PathBuf::from(v));
|
||||
}
|
||||
match std::env::var_os("HOME").filter(|h| !h.is_empty() && std::path::Path::new(h).is_absolute()) {
|
||||
Some(home) => Ok(std::path::PathBuf::from(home).join(".local").join("share")),
|
||||
None => Err(DevIconError::NoHome),
|
||||
}
|
||||
}
|
||||
|
||||
/// The XDG data dirs to search for an existing entry, user dir first, then `$XDG_DATA_DIRS` (or
|
||||
/// the spec default `/usr/local/share:/usr/share`). Only absolute paths are taken; the spec
|
||||
/// requires them and a relative one would read the working directory.
|
||||
fn search_dirs(user: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = vec![user.to_path_buf()];
|
||||
let system = std::env::var_os("XDG_DATA_DIRS")
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|v| v.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
for dir in system.split(':') {
|
||||
if std::path::Path::new(dir).is_absolute() {
|
||||
dirs.push(std::path::PathBuf::from(dir));
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Whether the `.desktop` body's `Exec` points at our binary exactly (a stale dev entry from
|
||||
/// another checkout — `/a/filmcraft-old` is not `/a/filmcraft` — does not, and is rewritten; a
|
||||
/// packaged `Exec=filmcraft` never matches a source-tree path).
|
||||
fn desktop_file_exec_is_ours(body: &str, exe: &str) -> bool {
|
||||
exec_line(body).is_some_and(|arg| arg == exe)
|
||||
}
|
||||
|
||||
/// The first `Exec=` argument (the executable), unescaped through both Desktop Entry layers:
|
||||
/// first the string layer of the file value (`\\` `\s` `\n` `\t` `\r`, per
|
||||
/// <https://specifications.freedesktop.org/desktop-entry/latest/>), then the shell layer of the
|
||||
/// argument (double quotes, and `\` before `` ` `"` `\` `$` inside them). `None` when there is no
|
||||
/// `Exec=` line or its first argument cannot be parsed.
|
||||
fn exec_line(body: &str) -> Option<String> {
|
||||
let value = body.lines().find(|l| l.starts_with("Exec=")).and_then(|l| l.strip_prefix("Exec="))?;
|
||||
let value = unescape_string_value(value);
|
||||
if let Some(rest) = value.strip_prefix('"') {
|
||||
let mut out = String::new();
|
||||
let mut chars = rest.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => return Some(out),
|
||||
'\\' => match chars.next() {
|
||||
Some(e @ ('\\' | '"' | '`' | '$')) => out.push(e),
|
||||
Some(e) => {
|
||||
out.push('\\');
|
||||
out.push(e);
|
||||
}
|
||||
None => return None, // a dangling escape ends the argument
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
None // no closing quote
|
||||
} else {
|
||||
Some(value.split(' ').next().unwrap_or_default().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo the desktop entry's string layer: `\\` `\s` `\n` `\t` `\r` are the defined sequences; a
|
||||
/// backslash before anything else is kept as the character itself (lenient, like desktop file
|
||||
/// parsers in the wild).
|
||||
fn unescape_string_value(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut chars = value.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('\\') => out.push('\\'),
|
||||
Some('s') => out.push(' '),
|
||||
Some('n') => out.push('\n'),
|
||||
Some('t') => out.push('\t'),
|
||||
Some('r') => out.push('\r'),
|
||||
Some(e) => out.push(e),
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `.desktop` body: same fields as `packaging/linux/ai.storyteller.filmcraft.desktop`, with
|
||||
/// `Exec` pointing at this exact binary (a dev build isn't on `$PATH`, so `TryExec` is left out)
|
||||
/// and the icon by theme name.
|
||||
fn desktop_file_content(app_id: &str, name: &str, exe: &str) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("[Desktop Entry]\n");
|
||||
out.push_str("Type=Application\n");
|
||||
out.push_str(&format!("Name={name}\n"));
|
||||
out.push_str("GenericName=Video Editor\n");
|
||||
out.push_str("Comment=Edit video, color and sound\n");
|
||||
out.push_str(&format!("Exec=\"{}\" %F\n", escape_exec_argument(exe)));
|
||||
out.push_str(&format!("Icon={app_id}\n"));
|
||||
out.push_str("Terminal=false\n");
|
||||
out.push_str("StartupNotify=true\n");
|
||||
out.push_str(&format!("StartupWMClass={app_id}\n"));
|
||||
out.push_str("Categories=AudioVideo;Video;AudioVideoEditing;\n");
|
||||
out.push_str("Keywords=video;editor;film;timeline;color;grading;nle;\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Escape `s` for the `Exec=` value's two layers, like `packaging/linux/install.sh`: first the
|
||||
/// shell layer (`` ` `` `"` `$` `\` get a backslash, and the argument is double-quoted), then
|
||||
/// the string layer of the file value (every backslash doubled).
|
||||
fn escape_exec_argument(s: &str) -> String {
|
||||
let mut shell = String::with_capacity(s.len() + 2);
|
||||
for c in s.chars() {
|
||||
if matches!(c, '`' | '"' | '$' | '\\') {
|
||||
shell.push('\\');
|
||||
}
|
||||
shell.push(c);
|
||||
}
|
||||
shell.replace('\\', "\\\\")
|
||||
}
|
||||
|
||||
/// Everything that can keep the dev icon out of the taskbar; logged, never fatal.
|
||||
#[derive(Debug)]
|
||||
enum DevIconError {
|
||||
Io(std::io::Error),
|
||||
NoHome,
|
||||
BadExecPath,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DevIconError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DevIconError::Io(e) => write!(f, "{e}"),
|
||||
DevIconError::NoHome => write!(f, "no home directory for the user data dir"),
|
||||
DevIconError::BadExecPath => {
|
||||
write!(f, "the binary path has a character a desktop entry cannot carry (=, % or a control character), or is not valid UTF-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The entry mirrors the packaged one, derived from the real file so the two cannot drift:
|
||||
/// minus `TryExec` (a dev build isn't on `$PATH`), plus the quoted dev `Exec` path.
|
||||
/// The icons the entry installs exist in the checkout.
|
||||
#[test]
|
||||
fn icon_sources_exist_in_the_checkout() {
|
||||
for size in ICON_SIZES {
|
||||
let p = std::path::Path::new(ICON_DIR).join(format!("hicolor/{size}x{size}/apps/ai.storyteller.filmcraft.png"));
|
||||
assert!(p.is_file(), "{}", p.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_file_matches_the_packaged_fields() {
|
||||
let packaged = include_str!("../../../packaging/linux/ai.storyteller.filmcraft.desktop");
|
||||
let expected = packaged
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("TryExec="))
|
||||
.map(|l| if l.starts_with("Exec=") { "Exec=\"/target/debug/filmcraft\" %F" } else { l })
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
let body = desktop_file_content("ai.storyteller.filmcraft", "FilmCraft", "/target/debug/filmcraft");
|
||||
assert_eq!(body, expected);
|
||||
}
|
||||
|
||||
/// Both layers are escaped, like install.sh: shell first (`` ` `` `"` `$` `\` get a
|
||||
/// backslash), then *every* backslash doubled for the file value — including the escape
|
||||
/// backslashes just added.
|
||||
#[test]
|
||||
fn exec_escapes_both_desktop_entry_layers() {
|
||||
assert_eq!(escape_exec_argument("/opt/film craft/filmcraft"), "/opt/film craft/filmcraft");
|
||||
// a\b → shell a\\b → string a\\\\b
|
||||
assert_eq!(escape_exec_argument("a\\b"), "a\\\\\\\\b");
|
||||
// a$b → shell a\$b → string a\\$b
|
||||
assert_eq!(escape_exec_argument("a$b"), "a\\\\$b");
|
||||
}
|
||||
|
||||
/// A written entry reads back as the exact binary it was written for, through both layers.
|
||||
#[test]
|
||||
fn exec_round_trips_through_both_layers() {
|
||||
for exe in ["/home/dev/Projects/filmcraft/target/debug/filmcraft", "/opt/film craft/filmcraft", "/a$b\"c`d\\e"] {
|
||||
let body = desktop_file_content("ai.storyteller.filmcraft", "FilmCraft", exe);
|
||||
assert_eq!(exec_line(&body).as_deref(), Some(exe));
|
||||
assert!(desktop_file_exec_is_ours(&body, exe));
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the exact executable counts: another checkout's path, a path with our path as a
|
||||
/// prefix, and a packaged `Exec=filmcraft` are all someone else's entry.
|
||||
#[test]
|
||||
fn desktop_file_exec_is_ours_matches_only_our_binary() {
|
||||
let body = desktop_file_content("ai.storyteller.filmcraft", "FilmCraft", "/a/filmcraft");
|
||||
assert!(!desktop_file_exec_is_ours(&body, "/a/filmcraft-old"));
|
||||
assert!(!desktop_file_exec_is_ours(&body, "/b/filmcraft"));
|
||||
assert!(!desktop_file_exec_is_ours("Exec=filmcraft %F\n", "/a/filmcraft"));
|
||||
assert!(!desktop_file_exec_is_ours("Name=filmcraft\n", "/a/filmcraft"));
|
||||
assert!(!desktop_file_exec_is_ours("Exec=\"dangling\n", "/a/filmcraft"));
|
||||
}
|
||||
|
||||
/// The string layer is undone before the argument is split: `\s` becomes a space, which
|
||||
/// ends a bare argument (the shell layer's quoting, not `\s`, is how a space travels), and
|
||||
/// `\\` is one backslash. install.sh always quotes, so a space in our own `Exec` never
|
||||
/// splits.
|
||||
#[test]
|
||||
fn exec_reads_the_string_layer() {
|
||||
assert_eq!(exec_line("Exec=/a\\sb %F\n").as_deref(), Some("/a"));
|
||||
assert_eq!(exec_line("Exec=\"/a\\sb\" %F\n").as_deref(), Some("/a b"));
|
||||
assert_eq!(exec_line("Exec=/a\\\\b %F\n").as_deref(), Some("/a\\b"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
//! Extension filters for native file dialogs.
|
||||
//!
|
||||
//! Every filtered file dialog (import, relink, open file/preset, open/save project) builds
|
||||
//! its filter with [`extensions`]. On Linux and the BSDs, rfd's XDG portal, GTK and Zenity
|
||||
//! backends turn each extension into a case-sensitive `*.{ext}` glob, so camera files such
|
||||
//! as `shot.MP4` or `shot.Mp4` would be hidden. The helper appends one bracket pattern per
|
||||
//! extension (`mp4` -> `[mM][pP]4`) after the literal extensions, which stay first so save
|
||||
//! dialogs keep their normal default suffix. macOS and Windows dialogs already ignore case
|
||||
//! and receive the plain list.
|
||||
|
||||
pub fn extensions(exts: &[&str]) -> Vec<String> {
|
||||
let extensions: Vec<_> = exts.iter().map(|ext| (*ext).to_owned()).collect();
|
||||
// XDG portal/Zenity globs are case-sensitive. Keep the literal extensions first so
|
||||
// Save dialogs can still infer a normal default suffix, then cover mixed case too.
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let mut extensions = extensions;
|
||||
for ext in exts {
|
||||
if !ext.bytes().any(|b| b.is_ascii_alphabetic()) {
|
||||
continue;
|
||||
}
|
||||
let mut pattern = String::new();
|
||||
for c in ext.chars() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
pattern.extend(['[', c.to_ascii_lowercase(), c.to_ascii_uppercase(), ']']);
|
||||
} else {
|
||||
pattern.push(c);
|
||||
}
|
||||
}
|
||||
extensions.push(pattern);
|
||||
}
|
||||
extensions
|
||||
}
|
||||
// Native macOS and Windows filters take ordinary extensions.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extensions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extensions;
|
||||
|
||||
// rfd's XDG portal and Zenity backends turn each extension into `*.{ext}`.
|
||||
// Exercise those case-sensitive patterns against filenames, as the picker does.
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
#[test]
|
||||
fn camera_files_are_visible_in_every_capitalization() {
|
||||
let filters: Vec<_> = extensions(&["mp4", "mov", "m2ts", "wav", "fcproj"]).iter().map(|ext| glob::Pattern::new(&format!("*.{ext}")).unwrap()).collect();
|
||||
for name in ["camera.mp4", "camera.MP4", "camera.Mp4", "clip.MOV", "clip.moV", "take.M2TS", "sound.WaV", "Edit.FCPROJ", "Edit.FcProj"] {
|
||||
assert!(filters.iter().any(|f| f.matches(name)), "the picker hides {name}");
|
||||
}
|
||||
for name in ["camera.mp4.bak", "camera.mp44", "camera.xmp4", "notes.txt", "mp4"] {
|
||||
assert!(!filters.iter().any(|f| f.matches(name)), "the picker admits {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_filters_keep_the_literal_default_extension() {
|
||||
assert_eq!(extensions(&["mp4", "mov"]).first().map(String::as_str), Some("mp4"));
|
||||
assert!(extensions(&[]).is_empty());
|
||||
assert_eq!(extensions(&["*", "", "123"]), ["*", "", "123"]);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
#[test]
|
||||
fn native_filters_receive_only_literal_extensions() {
|
||||
assert_eq!(extensions(&["mp4", "mov", "fcproj"]), ["mp4", "mov", "fcproj"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,888 +0,0 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! # The adapter
|
||||
//! In the default mode on Windows the adapter is wgpu's own choice, except for a GPU that Vulkan
|
||||
//! lists through two installed versions of one driver: there the newer one draws, because a
|
||||
//! window drawn through the stale one dies inside the driver (`amdvlk64.dll`; see
|
||||
//! `filmcraft_gpu::adapter`). That case is avoided up front instead of left to the fallback
|
||||
//! below, which would end in DirectX 12 only.
|
||||
//!
|
||||
//! # 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/<pid>-<n>.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 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};
|
||||
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{self, Backends},
|
||||
};
|
||||
|
||||
/// The saved graphics state in the data folder: `{"mode": "<mode>", "failures": <n>}`.
|
||||
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 <name>`).
|
||||
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> {
|
||||
[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<Mode> {
|
||||
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<bool>,
|
||||
}
|
||||
|
||||
/// 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<Saved>, 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<String> {
|
||||
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<Saved> {
|
||||
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 (`<pid>-<n>.json`) and text; `None` for anything else.
|
||||
fn parse_attempt(file_name: &str, text: &str) -> Option<Attempt> {
|
||||
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<Attempt>)> {
|
||||
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<PathBuf>,
|
||||
/// This start's attempt file (with `state`).
|
||||
attempt: Option<PathBuf>,
|
||||
mode: Mode,
|
||||
/// [`Self::reset_to_default`] was used: a later [`Self::confirm`] must not save this mode over it.
|
||||
reset: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<Mode>) -> 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<Mode>, 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<Attempt> = 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<Backends>) {
|
||||
apply_for(options, mode, backend_override, cfg!(target_os = "windows"));
|
||||
}
|
||||
|
||||
fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>, windows: bool) {
|
||||
let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else {
|
||||
return;
|
||||
};
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
|
||||
// compute shader when the device is created, one that Metal can't compile on an Intel Iris
|
||||
// Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns
|
||||
// the check back on.
|
||||
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
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);
|
||||
// wgpu's own choice of adapter, except for a GPU that Windows lists through two
|
||||
// versions of one driver: the first entry can be a stale one that crashes inside the
|
||||
// driver (`filmcraft_gpu::adapter`). A selector replaces `power_preference`
|
||||
// (`WGPU_POWER_PREF`), so it takes that along.
|
||||
let power = create.power_preference;
|
||||
create.native_adapter_selector =
|
||||
Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| filmcraft_gpu::adapter::pick(adapters, surface, power)));
|
||||
}
|
||||
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<wgpu::Adapter, String> {
|
||||
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::*;
|
||||
|
||||
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<Backends>, windows: bool) -> (Backends, bool) {
|
||||
let mut options = NativeOptions::default();
|
||||
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.native_adapter_selector.is_some())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mode_validates_indirect_calls() {
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
|
||||
return;
|
||||
}
|
||||
for mode in [Mode::Default, Mode::Dx12, Mode::Software] {
|
||||
let mut options = NativeOptions::default();
|
||||
apply_for(&mut options, mode, None, false);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
// Intel Metal can't compile the validation's compute shader, and the device is lost.
|
||||
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_leaves_out_gl() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
|
||||
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
let (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));
|
||||
// and chooses the adapter itself: a GPU listed through two versions of one driver
|
||||
assert!(selector);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_windows_default_and_software_modes_choose_the_adapter() {
|
||||
for backend_override in [None, Some(Backends::VULKAN), Some(Backends::DX12)] {
|
||||
assert!(configured(Mode::Default, backend_override, true).1);
|
||||
// elsewhere, and in DirectX 12 only mode, the adapter is wgpu's own choice
|
||||
assert!(!configured(Mode::Default, backend_override, false).1);
|
||||
assert!(!configured(Mode::Dx12, backend_override, true).1);
|
||||
}
|
||||
assert!(configured(Mode::Software, None, true).1 && configured(Mode::Software, None, false).1);
|
||||
}
|
||||
|
||||
#[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(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<Saved> {
|
||||
Some(Saved { mode, failures })
|
||||
}
|
||||
|
||||
fn dead(mode: Mode) -> Attempt {
|
||||
Attempt { pid: 1, mode, started: NOW - 100 }
|
||||
}
|
||||
|
||||
fn mode_after(s: Option<Saved>, 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<Saved> {
|
||||
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 <the last mode>` 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,562 +0,0 @@
|
|||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (GPU compositor fallbacks,
|
||||
//! audio output problems, a panicked frame job or audio mixer, container warnings) vanished. A
|
||||
//! launch from a desktop menu has no terminal, so the file is what a bug report can attach:
|
||||
//! `<data dir>/Logs/filmcraft.log`, next to the crash logs (`--data-dir`, `FILMCRAFT_DATA_DIR` or
|
||||
//! `filmcraft_engine::autosave::default_data_dir`; Help ▸ Reveal Log Files opens the folder). Each
|
||||
//! start moves the previous log to `filmcraft.1.log` (and that one to `.2`), so the log of a run
|
||||
//! that crashed survives the next launch.
|
||||
//!
|
||||
//! Levels: `info` for FilmCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
||||
//! `warn,filmcraft_codecs=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
||||
//! that starts with it (`filmcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the data directory is known are kept (up to [`MAX_PENDING`]) and written
|
||||
//! once the file is attached. Writing never panics: a file that can't be created or written leaves
|
||||
//! standard error as the only sink.
|
||||
//!
|
||||
//! Realtime audio: the cpal callbacks (`audio.rs`, `audio_in.rs`, the play-ahead reader) do not
|
||||
//! log; they report through flags and a one-time `eprintln!`. A record takes the sink lock only for
|
||||
//! one `write_all` of an already formatted line (standard error is written outside the lock), so a
|
||||
//! logging thread such as the audio mixer never waits on more than one other line being written.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log directory.
|
||||
pub const LOG_FILE: &str = "filmcraft.log";
|
||||
/// The log directory inside the data directory (shared with the crash logs and Reveal Log Files).
|
||||
pub const LOG_DIR: &str = "Logs";
|
||||
/// How many previous logs are kept (`filmcraft.1.log` … `filmcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,filmcraft*=info";
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("filmcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// No file will come: drop the kept lines and keep none from now on.
|
||||
pub fn close(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink (and
|
||||
// stop buffering, since no file will come).
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
self.stderr_only();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file this run (no data directory, or it can't be used): stop keeping records for one.
|
||||
pub fn stderr_only(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Format before taking the lock, so the lock covers one write only.
|
||||
let thread = std::thread::current();
|
||||
let line = format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] once the data directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("filmcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_filmcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("filmcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("filmcraft::audio"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("filmcraft_ui_egui::play_ahead"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("filmcraft_codecs::mxf"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,filmcraft_codecs=trace,filmcraft_codecs::mxf=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("filmcraft_codecs::ogg"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("filmcraft_codecs::mxf"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("warn,filmcraft_ui*=debug").level_for("filmcraft_ui_egui::frames"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,filmcraft_ui*=debug").level_for("filmcraft_engine"), LevelFilter::Warn);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive; the last bare level wins.
|
||||
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "a".repeat(100_000);
|
||||
let specs = [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"filmcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=debug",
|
||||
"a=b=c",
|
||||
"warn,,filmcraft=DEBUG",
|
||||
" \t ",
|
||||
"\u{0}",
|
||||
"é*=trace",
|
||||
&long,
|
||||
];
|
||||
for spec in specs {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["filmcraft", "", "🦀", "é", "a::b"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,filmcraft=DEBUG").level_for("filmcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
||||
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
||||
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("filmcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("filmcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("filmcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
assert!(rotate(&dir.join("below")).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "filmcraft_ui_egui", "audio output unavailable: no output device"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] filmcraft_ui_egui: audio output unavailable: no output device\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_pending_lines_drop_nothing() {
|
||||
let dir = temp_dir("exact");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "filmcraft", "FilmCraft starting");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Debug, "filmcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("filmcraft: FilmCraft starting")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("filmcraft_engine::autosave").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// A second launch rotates: the first log becomes `.1`.
|
||||
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("filmcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..4 {
|
||||
s.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
record(&logger, log::Level::Info, "filmcraft", "from a worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 400);
|
||||
assert!(text.lines().all(|l| l.ends_with("filmcraft: from a worker")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
||||
assert!(e.contains(&dir.display().to_string()), "{e}");
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_lock_does_not_stop_logging() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = logger.sink.lock();
|
||||
panic!("poison the sink");
|
||||
}));
|
||||
assert!(logger.sink.is_poisoned());
|
||||
record(&logger, log::Level::Info, "filmcraft", "after the panic");
|
||||
assert!(read(&path).contains("after the panic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,23 +16,14 @@
|
|||
//! with unsaved changes the app asks to recover them; `--recover` recovers the newest without
|
||||
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
|
||||
//! Unsaved Changes…).
|
||||
//!
|
||||
//! `log` records go to standard error and `<data dir>/Logs/filmcraft.log` (`RUST_LOG` sets the
|
||||
//! levels); see [`logging`].
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod app_nap;
|
||||
mod appearance;
|
||||
mod args;
|
||||
mod audio;
|
||||
mod audio_in;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod dev_icon;
|
||||
mod file_filters;
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod window_raise;
|
||||
|
|
@ -43,8 +34,6 @@ 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";
|
||||
|
|
@ -77,11 +66,9 @@ fn tell(text: &str, error: bool) {
|
|||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up record are logged (`logging`).
|
||||
let logger = logging::install();
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
|
||||
let launch = match args::parse(env_port, std::env::args_os().skip(1)) {
|
||||
let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
|
||||
Ok(Cli::Run(l)) => l,
|
||||
Ok(Cli::Help) => {
|
||||
tell(args::USAGE, false);
|
||||
|
|
@ -96,76 +83,14 @@ fn main() -> eframe::Result {
|
|||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
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.
|
||||
if let Some(logger) = logger {
|
||||
match log_dir.as_deref().map(|d| logger.attach_dir(d)) {
|
||||
Some(Ok(path)) => log::info!("log file {}", path.display()),
|
||||
Some(Err(e)) => log::warn!("no log file: {e}"),
|
||||
None => {
|
||||
logger.stderr_only();
|
||||
log::warn!("no log file: no data directory (set FILMCRAFT_DATA_DIR or --data-dir)");
|
||||
}
|
||||
}
|
||||
}
|
||||
app_nap::disable();
|
||||
#[cfg(target_os = "linux")]
|
||||
dev_icon::ensure_dev_desktop_entry();
|
||||
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
|
||||
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
|
||||
filmcraft_ui_egui::crash::install(log_dir);
|
||||
filmcraft_ui_egui::crash::install(data_dir.clone().or_else(default_data_dir).map(|d| d.join("Logs")));
|
||||
// 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();
|
||||
// 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;
|
||||
};
|
||||
// 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 {
|
||||
let msg = format!("FilmCraft could not start its window: {e}\n\nUpdating the graphics driver usually fixes this.");
|
||||
log::error!("{msg}");
|
||||
filmcraft_ui_egui::crash::record(&msg);
|
||||
let _ = rfd::MessageDialog::new().set_title("FilmCraft").set_description(&msg).set_level(rfd::MessageLevel::Error).show();
|
||||
}
|
||||
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 {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FilmCraft")
|
||||
.with_inner_size([1600.0, 980.0])
|
||||
|
|
@ -175,187 +100,116 @@ fn native_options(agent: bool) -> eframe::NativeOptions {
|
|||
.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_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(agent),
|
||||
event_loop_builder: agent_event_loop(control_port.is_some()),
|
||||
..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<AtomicBool>) -> 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 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) {
|
||||
eprintln!("filmcraft: auto-save and crash recovery unavailable: {e}");
|
||||
}
|
||||
}),
|
||||
);
|
||||
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}");
|
||||
// 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})) {
|
||||
eprintln!("filmcraft: {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})) {
|
||||
eprintln!("filmcraft: {e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
}
|
||||
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<String> = 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) => eprintln!("filmcraft: recovered {r}"),
|
||||
Err(e) => eprintln!("filmcraft: recovery failed: {e}"),
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
if recover == Some(false) {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
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<filmcraft_ui_egui::ClipboardMedia> {
|
||||
let mut clipboard = arboard::Clipboard::new().ok()?;
|
||||
if let Ok(files) = clipboard.get().file_list()
|
||||
&& !files.is_empty()
|
||||
{
|
||||
return Some(filmcraft_ui_egui::ClipboardMedia::Files(files.iter().map(|p| p.to_string_lossy().into_owned()).collect()));
|
||||
if let Some(out) = audio::CpalOut::new() {
|
||||
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`)
|
||||
app.audio = Some(Box::new(out));
|
||||
}
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}));
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
|
||||
rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
app.hooks.raise_without_focus = Some(Box::new(|| {
|
||||
window_raise::raise_without_focus();
|
||||
}));
|
||||
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_open_project =
|
||||
Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
|
||||
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))
|
||||
}),
|
||||
);
|
||||
// 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 {
|
||||
let msg = format!("FilmCraft could not start its window: {e}\n\nUpdating the graphics driver usually fixes this.");
|
||||
eprintln!("filmcraft: {msg}");
|
||||
filmcraft_ui_egui::crash::record(&msg);
|
||||
let _ = rfd::MessageDialog::new().set_title("FilmCraft").set_description(&msg).set_level(rfd::MessageLevel::Error).show();
|
||||
}
|
||||
let image = clipboard.get_image().ok()?;
|
||||
Some(filmcraft_ui_egui::ClipboardMedia::Image { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
|
||||
started
|
||||
}
|
||||
|
||||
/// Open a file in its default application, or reveal it in the file manager (Edit Original,
|
||||
|
|
@ -377,7 +231,6 @@ 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 });
|
||||
|
|
@ -386,30 +239,6 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> {
|
|||
cmd.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
|
||||
}
|
||||
|
||||
/// The GPU export frame renderer (filmcraft-gpu's off-screen compositor behind filmcraft-export's
|
||||
/// frame-renderer hook): exports with GPU rendering Auto composite on the GPU and fall back to the
|
||||
/// CPU reference renderer wherever it cannot.
|
||||
struct GpuFrameRenderer(filmcraft_gpu::ExportRenderer);
|
||||
|
||||
impl filmcraft_export::FrameRenderer for GpuFrameRenderer {
|
||||
fn render(
|
||||
&mut self,
|
||||
project: &filmcraft_project::Project,
|
||||
seq: filmcraft_project::ItemId,
|
||||
t: filmcraft_time::Tick,
|
||||
opts: filmcraft_render::RenderOptions,
|
||||
sources: &dyn filmcraft_render::SourceProvider,
|
||||
) -> Option<filmcraft_render::Image> {
|
||||
self.0.render(project, seq, t, opts, sources)
|
||||
}
|
||||
}
|
||||
|
||||
fn register_gpu_frame_renderer() {
|
||||
filmcraft_export::register_frame_renderer(|| {
|
||||
filmcraft_gpu::ExportRenderer::new().map(|r| Box::new(GpuFrameRenderer(r)) as Box<dyn filmcraft_export::FrameRenderer>)
|
||||
});
|
||||
}
|
||||
|
||||
/// Put the OS hardware video decoders in front of our own. Registered in a statement of its own:
|
||||
/// a log macro does not evaluate its arguments while no logger takes its level, which left the
|
||||
/// hardware decoders out of every run.
|
||||
|
|
@ -438,80 +267,6 @@ fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
|
|||
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<Vec<String>> {
|
||||
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 project_exts = project_patterns();
|
||||
let dialog = prepare_dialog(dialog);
|
||||
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, &project_exts).pick_file().map(path).into_iter().collect(),
|
||||
FileDialog::SaveProject { name } => {
|
||||
rfd::FileDialog::new().add_filter(&project, &project_exts).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::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
});
|
||||
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
|
||||
}
|
||||
|
||||
/// A dialog as the Linux dialog thread shows it: filter names translated (here, on the UI thread:
|
||||
/// the interface language is per thread) and every extension list turned into the
|
||||
/// case-insensitive patterns the synchronous pickers use, so `CLIP.MP4` stays visible (#262).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn prepare_dialog(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> filmcraft_ui_egui::native_dialogs::FileDialog {
|
||||
use filmcraft_ui_egui::native_dialogs::FileDialog;
|
||||
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
|
||||
let patterns = |exts: Vec<String>| file_filters::extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
match dialog {
|
||||
FileDialog::OpenFiles { exts } => FileDialog::OpenFiles { exts: patterns(exts) },
|
||||
FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts: patterns(exts) },
|
||||
FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts: patterns(exts), name },
|
||||
FileDialog::Relink { exts, hint } => FileDialog::Relink { exts: patterns(exts), hint },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The project dialogs' filter patterns (`EDIT.FCPROJ` included).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn project_patterns() -> Vec<String> {
|
||||
file_filters::extensions(&["fcproj"])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Start-up registers the hardware decoders whether or not anything is logged (no logger is
|
||||
|
|
@ -520,36 +275,6 @@ mod tests {
|
|||
fn startup_registers_the_hardware_decoders_without_a_logger() {
|
||||
assert!(!log::log_enabled!(log::Level::Info));
|
||||
let hardware = super::register_hardware_decoders();
|
||||
// always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there
|
||||
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
|
||||
/// #262 on Linux: the dialogs shown off the UI thread (#260) filter with the same
|
||||
/// case-insensitive patterns as the synchronous pickers, so upper- and mixed-case files show.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn off_thread_dialogs_keep_case_insensitive_filters() {
|
||||
use filmcraft_ui_egui::RelinkHint;
|
||||
use filmcraft_ui_egui::native_dialogs::FileDialog;
|
||||
let mp4 = || vec!["mp4".to_string()];
|
||||
let hint = RelinkHint { command: "media.relink".into(), params: serde_json::json!({}) };
|
||||
for dialog in [
|
||||
FileDialog::OpenFiles { exts: mp4() },
|
||||
FileDialog::OpenFile { filter: "Media".into(), exts: mp4() },
|
||||
FileDialog::SaveAs { filter: "Media".into(), exts: mp4(), name: "clip.mp4".into() },
|
||||
FileDialog::Relink { exts: mp4(), hint },
|
||||
] {
|
||||
let exts = match super::prepare_dialog(dialog) {
|
||||
FileDialog::OpenFiles { exts } | FileDialog::OpenFile { exts, .. } | FileDialog::SaveAs { exts, .. } | FileDialog::Relink { exts, .. } => exts,
|
||||
other => panic!("the dialog changed kind: {other:?}"),
|
||||
};
|
||||
assert_eq!(exts.first().map(String::as_str), Some("mp4"), "the literal extension stays first (save suffix)");
|
||||
assert!(exts.iter().any(|e| e == "[mM][pP]4"), "{exts:?}");
|
||||
}
|
||||
let project = super::project_patterns();
|
||||
assert_eq!(project.first().map(String::as_str), Some("fcproj"));
|
||||
assert!(project.iter().any(|e| e == "[fF][cC][pP][rR][oO][jJ]"), "{project:?}");
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(target_os = "macos"), "{hardware:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, NATIVE_APP_MENU, menu_items};
|
||||
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, menu_items};
|
||||
use muda::accelerator::Accelerator;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
|
|
@ -19,53 +19,34 @@ fn accel(s: &str) -> Option<Accelerator> {
|
|||
mapped.parse().ok()
|
||||
}
|
||||
|
||||
/// Updates native item labels, key equivalents and enablement when the menu tree changes.
|
||||
pub type MenuUpdater = Box<dyn FnMut(&[Item])>;
|
||||
/// Updates native key equivalents when the active keyboard shortcuts change.
|
||||
pub type ShortcutUpdater = Box<dyn FnMut(&[Item])>;
|
||||
|
||||
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, MenuUpdater) {
|
||||
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, ShortcutUpdater) {
|
||||
let items = menu_items(app);
|
||||
// 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 native: Vec<(String, 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 ▸ <category> (Premiere's app-menu layout; General is Cmd+,)
|
||||
let settings = Submenu::new(app.ui.language.tr(settings_label), true);
|
||||
titles.push((settings_label.into(), settings.clone()));
|
||||
let settings = Submenu::new(app.ui.language.tr("Settings"), true);
|
||||
titles.push(("Settings".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, it.enabled, it.shortcut.as_deref().and_then(accel));
|
||||
native.push((it.clone(), mi.clone()));
|
||||
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 _ = 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(&[
|
||||
&about,
|
||||
&discord,
|
||||
&MenuItem::with_id("app.about", "About FilmCraft", true, None),
|
||||
&MenuItem::with_id("help.discord", "Join the ArtCraft Discord…", true, None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&settings,
|
||||
&PredefinedMenuItem::separator(),
|
||||
hide,
|
||||
hide_others,
|
||||
&PredefinedMenuItem::hide(None),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
quit,
|
||||
&PredefinedMenuItem::quit(None),
|
||||
]);
|
||||
let _ = bar.append(&app_menu);
|
||||
for top in MENUS {
|
||||
|
|
@ -76,8 +57,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
|||
// their first item appears
|
||||
let mut subs: Vec<(Vec<String>, Submenu)> = Vec::new();
|
||||
for it in mine {
|
||||
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 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 mut parent = sub.clone();
|
||||
for depth in 1..it.path.len() {
|
||||
let key = it.path[..=depth].to_vec();
|
||||
|
|
@ -110,56 +91,20 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
|||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
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") {
|
||||
let update: ShortcutUpdater = Box::new(move |items: &[Item]| {
|
||||
let language = if items.iter().any(|it| it.id == "app.language.japanese" && it.checked == Some(true)) {
|
||||
filmcraft_ui_egui::i18n::Language::Ja
|
||||
} else if checked("app.language.spanish") {
|
||||
filmcraft_ui_egui::i18n::Language::Es
|
||||
} else if checked("app.language.portuguese") {
|
||||
filmcraft_ui_egui::i18n::Language::PtBr
|
||||
} else if checked("app.language.ukrainian") {
|
||||
filmcraft_ui_egui::i18n::Language::Uk
|
||||
} else if checked("app.language.chinese") {
|
||||
filmcraft_ui_egui::i18n::Language::ZhCn
|
||||
} else if checked("app.language.german") {
|
||||
filmcraft_ui_egui::i18n::Language::De
|
||||
} else if checked("app.language.russian") {
|
||||
filmcraft_ui_egui::i18n::Language::Ru
|
||||
} else if checked("app.language.french") {
|
||||
filmcraft_ui_egui::i18n::Language::Fr
|
||||
} else if checked("app.language.indonesian") {
|
||||
filmcraft_ui_egui::i18n::Language::Id
|
||||
} else if checked("app.language.italian") {
|
||||
filmcraft_ui_egui::i18n::Language::It
|
||||
} else if checked("app.language.korean") {
|
||||
filmcraft_ui_egui::i18n::Language::Ko
|
||||
} else {
|
||||
filmcraft_ui_egui::i18n::Language::En
|
||||
};
|
||||
if language != shown_language {
|
||||
shown_language = language;
|
||||
for (title, submenu) in &titles {
|
||||
submenu.set_text(language.tr(title));
|
||||
}
|
||||
for (title, submenu) in &titles {
|
||||
submenu.set_text(language.tr(title));
|
||||
}
|
||||
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 {
|
||||
for (id, mi) in &native {
|
||||
if let Some(it) = items.iter().find(|i| &i.id == id) {
|
||||
mi.set_text(&it.label);
|
||||
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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,90 +0,0 @@
|
|||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ahmedhosnypro]
|
||||
real_name = "Ahmed Hosny"
|
||||
display_name = "Ahmed Hosny"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
sync_github_name = false
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.swillunderscore]
|
||||
display_name = "Swill Software"
|
||||
|
||||
|
||||
[people.IppSec]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ItzBloxy]
|
||||
display_name = "Bloxy"
|
||||
|
||||
[people.Hathor875]
|
||||
real_name = "Krzysztof Cieślik"
|
||||
sync_github_name = true
|
||||
|
||||
[people.ReReddo]
|
||||
display_name = "ReReddo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.liangforstudy]
|
||||
sync_github_name = true
|
||||
|
||||
[people.keller-lukas]
|
||||
real_name = "Lukas Keller"
|
||||
display_name = "Lukas Keller"
|
||||
sync_github_name = false
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ossini]
|
||||
sync_github_name = true
|
||||
|
||||
[people.opentechnepal]
|
||||
sync_github_name = true
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "Valentin Klein"
|
||||
sync_github_name = false
|
||||
|
||||
[people.n-delic]
|
||||
display_name = "n-delic"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
|
@ -48,9 +48,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -136,8 +136,7 @@ impl ProgramConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Element layout of `channelConfiguration` 1..=7 (tags as conventionally assigned).
|
||||
pub fn config_layout(channel_config: u8) -> Option<Vec<(ElementType, u8)>> {
|
||||
use ElementType::*;
|
||||
Some(match channel_config {
|
||||
|
|
@ -147,8 +146,7 @@ pub fn config_layout(channel_config: u8) -> Option<Vec<(ElementType, u8)>> {
|
|||
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 | 12 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)],
|
||||
11 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Sce, 1), (Lfe, 0)],
|
||||
7 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -390,14 +388,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -19,10 +19,6 @@ const ID_PCE: u32 = 5;
|
|||
const ID_FIL: u32 = 6;
|
||||
const ID_END: u32 = 7;
|
||||
|
||||
/// `extension_type` of a fill element carrying SBR data (ISO/IEC 14496-3 §4.5.2.8.2, Table 4.121).
|
||||
const EXT_SBR_DATA: u32 = 13;
|
||||
const EXT_SBR_DATA_CRC: u32 = 14;
|
||||
|
||||
/// One decoded `individual_channel_stream` before stereo processing and synthesis.
|
||||
struct Ics {
|
||||
info: IcsInfo,
|
||||
|
|
@ -49,8 +45,6 @@ pub struct Decoder {
|
|||
offsets: Vec<usize>,
|
||||
channels: Vec<ChannelState>,
|
||||
noise_state: u32,
|
||||
/// A fill element carried SBR data (implicitly signalled HE-AAC).
|
||||
sbr_seen: bool,
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
|
|
@ -73,7 +67,7 @@ impl Decoder {
|
|||
if layout.is_empty() && asc.channel_config != 0 {
|
||||
return Err(Error::InvalidConfig("unsupported channel configuration"));
|
||||
}
|
||||
let mut d = Decoder { asc, layout: Vec::new(), offsets: Vec::new(), channels: Vec::new(), noise_state: 0x2545_F491, sbr_seen: false };
|
||||
let mut d = Decoder { asc, layout: Vec::new(), offsets: Vec::new(), channels: Vec::new(), noise_state: 0x2545_F491 };
|
||||
d.set_layout(layout);
|
||||
Ok(d)
|
||||
}
|
||||
|
|
@ -96,12 +90,6 @@ impl Decoder {
|
|||
pub fn sample_rate(&self) -> u32 {
|
||||
self.asc.sample_rate
|
||||
}
|
||||
/// Whether the stream is HE-AAC: SBR signalled in the `AudioSpecificConfig` (explicit) or SBR
|
||||
/// data seen in a fill element of a decoded access unit (implicit). Only the AAC-LC core is
|
||||
/// decoded, at [`Self::sample_rate`]; the SBR high band is not reconstructed.
|
||||
pub fn sbr(&self) -> bool {
|
||||
self.asc.sbr || self.sbr_seen
|
||||
}
|
||||
pub fn channels(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
|
|
@ -186,11 +174,7 @@ impl Decoder {
|
|||
count += br.read_bits(8)? as usize;
|
||||
count -= 1;
|
||||
}
|
||||
if count > 0 {
|
||||
let ext = br.read_bits(4)?;
|
||||
self.sbr_seen |= matches!(ext, EXT_SBR_DATA | EXT_SBR_DATA_CRC);
|
||||
br.skip(count * 8 - 4)?;
|
||||
}
|
||||
br.skip(count * 8)?;
|
||||
}
|
||||
_ => break, // ID_END
|
||||
}
|
||||
|
|
@ -558,27 +542,3 @@ fn synth(state: &mut ChannelState, info: &IcsInfo, spec: &[f32]) -> Vec<f32> {
|
|||
state.prev_shape = cur;
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Implicitly signalled HE-AAC (an AAC-LC config) is recognised from the SBR fill element.
|
||||
#[test]
|
||||
fn sbr_fill_element_marks_implicit_he_aac() {
|
||||
// AAC-LC, 22.05 kHz, stereo
|
||||
let mut dec = Decoder::new(&[0x13, 0x90]).unwrap();
|
||||
assert!(!dec.sbr());
|
||||
// ID_FIL, count 1, extension_type EXT_SBR_DATA (+ 4 bits of payload), ID_END
|
||||
let _ = dec.decode(&[0b1100_0011, 0b1010_0001, 0b1100_0000]);
|
||||
assert!(dec.sbr());
|
||||
// other fill payloads (EXT_FILL) don't
|
||||
let mut dec = Decoder::new(&[0x13, 0x90]).unwrap();
|
||||
let _ = dec.decode(&[0b1100_0010, 0b0000_0001, 0b1100_0000]);
|
||||
assert!(!dec.sbr());
|
||||
// explicit signalling: AOT 5, core 22.05 kHz, stereo, extension 44.1 kHz, core AOT 2
|
||||
let dec = Decoder::new(&[0x2B, 0x92, 0x08, 0x00]).unwrap();
|
||||
assert!(dec.sbr());
|
||||
assert_eq!(dec.sample_rate(), 22_050);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "filmcraft-ac3"
|
||||
description = "Clean-room AC-3 and E-AC-3 (Dolby Digital / Dolby Digital Plus, ATSC A/52) audio decoder"
|
||||
description = "Clean-room AC-3 (Dolby Digital, ATSC A/52) audio decoder"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# filmcraft-ac3
|
||||
|
||||
Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder in pure Rust. Layer L0: depends only on
|
||||
Clean-room AC-3 (Dolby Digital) audio decoder in pure Rust. Layer L0: depends only on
|
||||
`thiserror`. Builds for `wasm32-unknown-unknown`; no `unsafe`. No GPL/LGPL code (FFmpeg,
|
||||
liba52) was consulted; FFmpeg is used only as an external fixture generator and test oracle.
|
||||
|
||||
|
|
@ -11,17 +11,14 @@ publicly available standard: §5 bit stream syntax and semantics, §6 decoding o
|
|||
exponents, §7.2 parametric bit allocation (Tables 7.6-7.16 extracted mechanically from the
|
||||
standard's text), §7.3 mantissas and dither, §7.4 coupling, §7.5 rematrixing, §7.7.1 dynamic
|
||||
range, §7.9 transforms and block switching. The window is computed as the Kaiser-Bessel derived
|
||||
window (α = 5) and checked against Table 7.33 to 6·10⁻⁶. E-AC-3: Annex E §E2 bit stream syntax
|
||||
(syncinfo, bsi, audfrm, audblk; Tables E2.1-E2.13, frame exponent strategies from Table E2.10),
|
||||
§E3.3 modified parameters, §E3.4.2 AHT helper variables (to detect AHT), §E3.6 spectral extension
|
||||
(attenuation Table E3.14, which is 2^(-(code + 1)(bin + 1) / 15), checked against the table).
|
||||
window (α = 5) and checked against Table 7.33 to 6·10⁻⁶.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
let mut dec = filmcraft_ac3::Decoder::new();
|
||||
let h = filmcraft_ac3::parse_header(frame)?; // rate, channels, frame size
|
||||
let out = dec.decode(frame)?; // h.samples() per channel, WAV channel order
|
||||
let out = dec.decode(frame)?; // 1536 samples per channel, WAV channel order
|
||||
dec.reset(); // before decoding from another position
|
||||
```
|
||||
|
||||
|
|
@ -29,7 +26,7 @@ dec.reset(); // before decoding from another
|
|||
|
||||
- Every audio coding mode (1+1, 1/0, 2/0, 3/0, 2/1, 3/1, 2/2, 3/2) with or without LFE; 32 /
|
||||
44.1 / 48 kHz and every frame size; bsid ≤ 8 (9 and 10, the half / quarter-rate variants, are
|
||||
accepted).
|
||||
accepted). E-AC-3 (bsid 11-16) is refused with `Error::Unsupported`.
|
||||
- D15 / D25 / D45 exponents with reuse across blocks; the parametric bit allocation in the
|
||||
standard's fixed-point steps, delta bit allocation, the all-zero SNR offset special case;
|
||||
grouped 3-, 5- and 11-level mantissas (groups shared across exponent sets), 7- and 15-level
|
||||
|
|
@ -46,26 +43,6 @@ dec.reset(); // before decoding from another
|
|||
- Errors (bad exponents, reserved codes, blocks running past the frame) return `Err`; garbage
|
||||
never panics.
|
||||
|
||||
## E-AC-3 (bsid 11-16)
|
||||
|
||||
- Independent substream 0 is decoded (stream types 0 and 2); dependent substreams (the channels
|
||||
beyond 5.1 of a 7.1 program) and further independent programs decode to no channels, so a 7.1
|
||||
stream plays as its 5.1 core. `Header::is_primary` tells them apart.
|
||||
- 1, 2, 3 or 6 blocks per syncframe (`Header::samples`), 32 / 44.1 / 48 kHz and the reduced rates
|
||||
16 / 22.05 / 24 kHz, every acmod with or without LFE.
|
||||
- The whole bsi (mixing and informational metadata, custom channel maps, addbsi) is parsed and
|
||||
skipped; audfrm: per-block or frame-based exponent strategies (exponents may be reused across
|
||||
syncframes of fewer than six blocks; the decoder keeps them until `reset`), the syntax-enable
|
||||
flags and their defaults, SNR offset strategies 1-3, converter fields, block start information.
|
||||
- Audio blocks share the AC-3 path: coupling with the default or transmitted band structure
|
||||
(`firstcplcos` / `firstcplleak` states), rematrixing with the Annex E band count, end mantissas
|
||||
derived from spectral extension.
|
||||
- Spectral extension: band structure, coordinates, blending factors, translation with wrap,
|
||||
banded RMS, the border / wrap-point notch filter, noise blending and scaling (§E3.6.4).
|
||||
- Refused with `Error::Unsupported`: the adaptive hybrid transform (when a channel uses it; `ahte`
|
||||
alone is fine) and enhanced coupling. Transient pre-noise processing data is read and not
|
||||
applied.
|
||||
|
||||
## Accuracy (vs FFmpeg as an external oracle)
|
||||
|
||||
AC-3 output is not bit-exact across decoders: zero-bit mantissas are filled with
|
||||
|
|
@ -79,17 +56,10 @@ FFmpeg's decode (worst channel SNR):
|
|||
| 1/0 32 kHz 96 kb/s | 94.1 dB | 95.7 dB |
|
||||
| 3/2 + LFE 48 kHz 448 kb/s | 81.4 dB | 90.2 dB |
|
||||
| 2/0 pink noise 128 kb/s (many zero-bit mantissas) | 27.7 dB | 27.9 dB |
|
||||
| E-AC-3 2/0 48 kHz 192 kb/s (tones, chirp) | 64.8 dB | 74.7 dB |
|
||||
| E-AC-3 2/0 44.1 kHz 64 kb/s with coupling and rematrixing | 65.5 dB | 66.1 dB |
|
||||
| E-AC-3 1/0 32 kHz 64 kb/s | 91.0 dB | 90.1 dB |
|
||||
| E-AC-3 3/2 + LFE 48 kHz 384 kb/s with mixing / informational metadata | 81.6 dB | 85.3 dB |
|
||||
| E-AC-3 2/0 pink noise 96 kb/s | 48.6 dB | 47.9 dB |
|
||||
|
||||
The noise row shows the difference is dither: two of our own decodes with different dither
|
||||
sequences differ as much as we differ from FFmpeg. In MPEG-2 TS (AVCHD-style `.mts`) and VOB the
|
||||
maximum sample difference to FFmpeg is 2.3·10⁻³. E-AC-3 through `filmcraft-codecs`
|
||||
(`tests/eac3_oracle.rs`): 5.1 in MP4 and Matroska 91.3 dB, stereo in QuickTime 87.0 dB and
|
||||
MPEG-TS 79.5 dB against FFmpeg's decode.
|
||||
maximum sample difference to FFmpeg is 2.3·10⁻³.
|
||||
|
||||
## Gaps
|
||||
|
||||
|
|
@ -97,10 +67,4 @@ MPEG-TS 79.5 dB against FFmpeg's decode.
|
|||
§7.9.4.2 but not checked against an oracle.
|
||||
- No downmixing, no heavy compression (`compr`) or dialogue normalisation (both are metadata
|
||||
for playback systems), no CRC check (corrupt frames are caught by consistency checks).
|
||||
- FFmpeg's E-AC-3 encoder writes six-block syncframes with frame-based exponent strategies,
|
||||
coupling with the default band structure and none of the optional per-block syntax. Spectral
|
||||
extension (no free encoder writes it), fewer blocks per syncframe, the reduced rates and the
|
||||
per-block syntax (shared with AC-3) are implemented from the standard and covered by unit tests
|
||||
on synthetic data, not by an oracle.
|
||||
- E-AC-3: no adaptive hybrid transform, enhanced coupling, transient pre-noise processing, or
|
||||
channels from dependent substreams (7.1 plays as 5.1).
|
||||
- E-AC-3 is not decoded.
|
||||
|
|
|
|||
|
|
@ -1,634 +0,0 @@
|
|||
//! E-AC-3 (ATSC A/52:2012 Annex E): the syncframe header and bit stream information (§E2.2.2),
|
||||
//! the audio frame (§E2.2.3) and spectral extension (§E2.2.4, §E3.6). The audio blocks share the
|
||||
//! AC-3 decoder (`Decoder::audio_block`), which reads the E-AC-3 differences from [`FrameInfo`].
|
||||
|
||||
use crate::{Bits, Error, Header, NFCHANS, Result};
|
||||
|
||||
/// Sample rates by fscod, and by fscod2 when fscod is 3 (Tables E2.2, E2.3).
|
||||
const RATES: [u32; 3] = [48_000, 44_100, 32_000];
|
||||
const REDUCED_RATES: [u32; 3] = [24_000, 22_050, 16_000];
|
||||
/// Audio blocks per syncframe by numblkscod (Table E2.4).
|
||||
const BLOCKS: [u8; 4] = [1, 2, 3, 6];
|
||||
|
||||
/// Exponent strategies of the six blocks for each frame-based strategy code (Table E2.10):
|
||||
/// 0 reuse, 1 D15, 2 D25, 3 D45.
|
||||
const FRAME_EXPSTR: [[u8; 6]; 32] = [
|
||||
[1, 0, 0, 0, 0, 0],
|
||||
[1, 0, 0, 0, 0, 3],
|
||||
[1, 0, 0, 0, 2, 0],
|
||||
[1, 0, 0, 0, 3, 3],
|
||||
[2, 0, 0, 2, 0, 0],
|
||||
[2, 0, 0, 2, 0, 3],
|
||||
[2, 0, 0, 3, 2, 0],
|
||||
[2, 0, 0, 3, 3, 3],
|
||||
[2, 0, 1, 0, 0, 0],
|
||||
[2, 0, 2, 0, 0, 3],
|
||||
[2, 0, 2, 0, 2, 0],
|
||||
[2, 0, 2, 0, 3, 3],
|
||||
[2, 0, 3, 2, 0, 0],
|
||||
[2, 0, 3, 2, 0, 3],
|
||||
[2, 0, 3, 3, 2, 0],
|
||||
[2, 0, 3, 3, 3, 3],
|
||||
[3, 1, 0, 0, 0, 0],
|
||||
[3, 1, 0, 0, 0, 3],
|
||||
[3, 2, 0, 0, 2, 0],
|
||||
[3, 2, 0, 0, 3, 3],
|
||||
[3, 2, 0, 2, 0, 0],
|
||||
[3, 2, 0, 2, 0, 3],
|
||||
[3, 2, 0, 3, 2, 0],
|
||||
[3, 2, 0, 3, 3, 3],
|
||||
[3, 3, 1, 0, 0, 0],
|
||||
[3, 3, 2, 0, 0, 3],
|
||||
[3, 3, 2, 0, 2, 0],
|
||||
[3, 3, 2, 0, 3, 3],
|
||||
[3, 3, 3, 2, 0, 0],
|
||||
[3, 3, 3, 2, 0, 3],
|
||||
[3, 3, 3, 3, 2, 0],
|
||||
[3, 3, 3, 3, 3, 3],
|
||||
];
|
||||
|
||||
/// Default coupling band structure by coupling sub-band (Table E2.12, "Default Coupling Banding
|
||||
/// Structure"; sub-band 0 never starts a combined band).
|
||||
const DEFAULT_CPLBNDSTRC: [bool; 18] = {
|
||||
let mut t = [false; 18];
|
||||
let ones = [8, 10, 11, 13, 14, 15, 16, 17];
|
||||
let mut i = 0;
|
||||
while i < ones.len() {
|
||||
t[ones[i]] = true;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// Default spectral extension band structure by sub-band (Table E2.11).
|
||||
const DEFAULT_SPXBNDSTRC: [bool; 17] = {
|
||||
let mut t = [false; 17];
|
||||
let mut i = 8;
|
||||
while i < 17 {
|
||||
t[i] = true;
|
||||
i += 2;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// First transform coefficient of spectral extension sub-band `k` (Table E3.13, k ≤ 17).
|
||||
const fn spx_band_bin(k: usize) -> usize {
|
||||
25 + 12 * k
|
||||
}
|
||||
|
||||
/// Spectral extension attenuation (Table E3.14): spxattentab[code][binindex] = 2^(-(code + 1) ·
|
||||
/// (binindex + 1) / 15), which the table lists to nine decimals.
|
||||
fn spx_atten(code: usize, binindex: usize) -> f32 {
|
||||
let e = (code as f64 + 1.0) * (binindex as f64 + 1.0) / 15.0;
|
||||
(-e).exp2() as f32
|
||||
}
|
||||
|
||||
/// Parse the header of an E-AC-3 syncframe (bsid 11-16) at the start of `b` (at least 7 bytes).
|
||||
pub(crate) fn parse_header(b: &[u8], bsid: u8) -> Result<Header> {
|
||||
let mut r = Bits::new(b.get(2..).unwrap_or_default());
|
||||
let strmtyp = r.read(2) as u8;
|
||||
let substreamid = r.read(3) as u8;
|
||||
let words = r.read(11) as usize + 1;
|
||||
let fscod = r.read(2) as usize;
|
||||
let (sample_rate, blocks) = match RATES.get(fscod) {
|
||||
Some(&rate) => (rate, BLOCKS[r.read(2) as usize & 3]),
|
||||
None => (*REDUCED_RATES.get(r.read(2) as usize).ok_or(Error::Invalid("reserved fscod2"))?, 6),
|
||||
};
|
||||
let acmod = r.read(3) as u8;
|
||||
let lfeon = r.bit();
|
||||
if strmtyp == 3 {
|
||||
return Err(Error::Unsupported("reserved E-AC-3 stream type 3".into()));
|
||||
}
|
||||
let frame_bytes = words * 2;
|
||||
if frame_bytes < 8 {
|
||||
return Err(Error::Invalid("E-AC-3 syncframe too short"));
|
||||
}
|
||||
// frame bits per second of audio
|
||||
let bits = frame_bytes as u64 * 8 * u64::from(sample_rate) / (256 * u64::from(blocks));
|
||||
let bitrate_kbps = u32::try_from(bits / 1000).unwrap_or(u32::MAX);
|
||||
Ok(Header { sample_rate, bitrate_kbps, frame_bytes, bsid, acmod, lfeon, blocks, strmtyp, substreamid })
|
||||
}
|
||||
|
||||
/// What the audio frame and blocks need from the bit stream information.
|
||||
pub(crate) struct Bsi {
|
||||
pub strmtyp: u8,
|
||||
/// Sample rate code for the bit allocation's hearing threshold: fscod, or fscod2 at the
|
||||
/// reduced rates.
|
||||
pub fscod: usize,
|
||||
pub numblkscod: usize,
|
||||
}
|
||||
|
||||
/// Read syncinfo and bsi (§E2.2.1-2), leaving `r` at the audio frame.
|
||||
pub(crate) fn read_bsi(r: &mut Bits) -> Result<Bsi> {
|
||||
r.skip(16); // syncword
|
||||
let strmtyp = r.read(2) as u8;
|
||||
r.skip(3 + 11); // substreamid, frmsiz
|
||||
let fscod = r.read(2) as usize;
|
||||
let (rate_code, numblkscod) = if fscod == 3 {
|
||||
let fscod2 = r.read(2) as usize;
|
||||
if fscod2 == 3 {
|
||||
return Err(Error::Invalid("reserved fscod2"));
|
||||
}
|
||||
(fscod2, 3)
|
||||
} else {
|
||||
(fscod, r.read(2) as usize)
|
||||
};
|
||||
let nblocks = BLOCKS[numblkscod & 3] as usize;
|
||||
let acmod = r.read(3) as usize;
|
||||
let lfeon = r.bit();
|
||||
r.skip(5 + 5); // bsid, dialnorm
|
||||
if r.bit() {
|
||||
r.skip(8); // compr
|
||||
}
|
||||
if acmod == 0 {
|
||||
r.skip(5); // dialnorm2
|
||||
if r.bit() {
|
||||
r.skip(8); // compr2
|
||||
}
|
||||
}
|
||||
if strmtyp == 1 && r.bit() {
|
||||
r.skip(16); // chanmap
|
||||
}
|
||||
if r.bit() {
|
||||
// mixing metadata
|
||||
if acmod > 2 {
|
||||
r.skip(2); // dmixmod
|
||||
}
|
||||
if acmod & 1 != 0 && acmod > 2 {
|
||||
r.skip(6); // ltrtcmixlev, lorocmixlev
|
||||
}
|
||||
if acmod & 4 != 0 {
|
||||
r.skip(6); // ltrtsurmixlev, lorosurmixlev
|
||||
}
|
||||
if lfeon && r.bit() {
|
||||
r.skip(5); // lfemixlevcod
|
||||
}
|
||||
if strmtyp == 0 {
|
||||
if r.bit() {
|
||||
r.skip(6); // pgmscl
|
||||
}
|
||||
if acmod == 0 && r.bit() {
|
||||
r.skip(6); // pgmscl2
|
||||
}
|
||||
if r.bit() {
|
||||
r.skip(6); // extpgmscl
|
||||
}
|
||||
match r.read(2) {
|
||||
1 => r.skip(5), // premixcmpsel, drcsrc, premixcmpscl
|
||||
2 => r.skip(12),
|
||||
// mixdeflen: the mixdata field (through mixdatafill) is mixdeflen + 2 bytes
|
||||
3 => {
|
||||
let len = r.read(5) as usize;
|
||||
r.skip(8 * (len + 2));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if acmod < 2 {
|
||||
if r.bit() {
|
||||
r.skip(14); // panmean, paninfo
|
||||
}
|
||||
if acmod == 0 && r.bit() {
|
||||
r.skip(14); // panmean2, paninfo2
|
||||
}
|
||||
}
|
||||
if r.bit() {
|
||||
// frame mixing configuration information
|
||||
if numblkscod == 0 {
|
||||
r.skip(5);
|
||||
} else {
|
||||
for _ in 0..nblocks {
|
||||
if r.bit() {
|
||||
r.skip(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.bit() {
|
||||
// informational metadata
|
||||
r.skip(5); // bsmod, copyrightb, origbs
|
||||
if acmod == 2 {
|
||||
r.skip(4); // dsurmod, dheadphonmod
|
||||
}
|
||||
if acmod >= 6 {
|
||||
r.skip(2); // dsurexmod
|
||||
}
|
||||
if r.bit() {
|
||||
r.skip(8); // mixlevel, roomtyp, adconvtyp
|
||||
}
|
||||
if acmod == 0 && r.bit() {
|
||||
r.skip(8); // mixlevel2, roomtyp2, adconvtyp2
|
||||
}
|
||||
if fscod < 3 {
|
||||
r.skip(1); // sourcefscod
|
||||
}
|
||||
}
|
||||
if strmtyp == 0 && numblkscod != 3 {
|
||||
r.skip(1); // convsync
|
||||
}
|
||||
if strmtyp == 2 && (numblkscod == 3 || r.bit()) {
|
||||
r.skip(6); // blkid → frmsizecod
|
||||
}
|
||||
if r.bit() {
|
||||
let l = r.read(6) as usize;
|
||||
r.skip((l + 1) * 8); // addbsi
|
||||
}
|
||||
Ok(Bsi { strmtyp, fscod: rate_code, numblkscod })
|
||||
}
|
||||
|
||||
/// The audio frame (§E2.2.3) and the state that runs from block to block within a syncframe.
|
||||
pub(crate) struct FrameInfo {
|
||||
pub strmtyp: u8,
|
||||
pub blkswe: bool,
|
||||
pub dithflage: bool,
|
||||
pub bamode: bool,
|
||||
pub frmfgaincode: bool,
|
||||
pub dbaflde: bool,
|
||||
pub skipflde: bool,
|
||||
pub snroffststr: u8,
|
||||
pub frmcsnroffst: i32,
|
||||
pub frmfsnroffst: i32,
|
||||
pub cplstre: [bool; 6],
|
||||
pub cplinu: [bool; 6],
|
||||
pub cplexpstr: [u8; 6],
|
||||
pub chexpstr: [[u8; 5]; 6],
|
||||
pub lfeexpstr: [u8; 6],
|
||||
/// Spectral extension attenuation code per channel (spxattencod), when in use.
|
||||
pub spxatten: [Option<usize>; 5],
|
||||
pub firstcplcos: [bool; 5],
|
||||
pub firstcplleak: bool,
|
||||
/// Coupling band structure by absolute coupling sub-band.
|
||||
pub cplbndstrc: [bool; 18],
|
||||
pub spx: Spx,
|
||||
}
|
||||
|
||||
impl FrameInfo {
|
||||
/// Read the audio frame. `words` is the syncframe size in 16-bit words.
|
||||
pub(crate) fn read(r: &mut Bits, bsi: &Bsi, acmod: usize, lfeon: bool, nblocks: usize, words: usize) -> Result<FrameInfo> {
|
||||
let nf = NFCHANS[acmod & 7];
|
||||
let six = bsi.numblkscod == 3;
|
||||
let (expstre, ahte) = if six { (r.bit(), r.bit()) } else { (true, false) };
|
||||
let snroffststr = r.read(2) as u8;
|
||||
if snroffststr == 3 {
|
||||
return Err(Error::Invalid("reserved SNR offset strategy"));
|
||||
}
|
||||
let transproce = r.bit();
|
||||
let mut e = FrameInfo {
|
||||
strmtyp: bsi.strmtyp,
|
||||
blkswe: r.bit(),
|
||||
dithflage: r.bit(),
|
||||
bamode: r.bit(),
|
||||
frmfgaincode: r.bit(),
|
||||
dbaflde: r.bit(),
|
||||
skipflde: r.bit(),
|
||||
snroffststr,
|
||||
frmcsnroffst: 0,
|
||||
frmfsnroffst: 0,
|
||||
cplstre: [false; 6],
|
||||
cplinu: [false; 6],
|
||||
cplexpstr: [0; 6],
|
||||
chexpstr: [[0; 5]; 6],
|
||||
lfeexpstr: [0; 6],
|
||||
spxatten: [None; 5],
|
||||
firstcplcos: [true; 5],
|
||||
firstcplleak: true,
|
||||
cplbndstrc: DEFAULT_CPLBNDSTRC,
|
||||
spx: Spx::new(),
|
||||
};
|
||||
let spxattene = r.bit();
|
||||
if acmod > 1 {
|
||||
e.cplstre[0] = true;
|
||||
e.cplinu[0] = r.bit();
|
||||
for blk in 1..nblocks {
|
||||
e.cplstre[blk] = r.bit();
|
||||
e.cplinu[blk] = if e.cplstre[blk] { r.bit() } else { e.cplinu[blk - 1] };
|
||||
}
|
||||
}
|
||||
if expstre {
|
||||
for blk in 0..nblocks {
|
||||
if e.cplinu[blk] {
|
||||
e.cplexpstr[blk] = r.read(2) as u8;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
e.chexpstr[blk][ch] = r.read(2) as u8;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// frame-based strategies (six blocks only)
|
||||
if acmod > 1 && e.cplinu.iter().any(|&c| c) {
|
||||
e.cplexpstr = FRAME_EXPSTR[r.read(5) as usize & 31];
|
||||
}
|
||||
for ch in 0..nf {
|
||||
let s = FRAME_EXPSTR[r.read(5) as usize & 31];
|
||||
for (blk, &v) in s.iter().enumerate() {
|
||||
e.chexpstr[blk][ch] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if lfeon {
|
||||
for blk in 0..nblocks {
|
||||
e.lfeexpstr[blk] = r.read(1) as u8;
|
||||
}
|
||||
}
|
||||
if bsi.strmtyp == 0 && (six || r.bit()) {
|
||||
r.skip(5 * nf); // convexpstr
|
||||
}
|
||||
if ahte {
|
||||
// AHT flags exist for channels whose exponents are sent once in the frame (§E3.4.2)
|
||||
let unsupported = || Err(Error::Unsupported("E-AC-3 adaptive hybrid transform".into()));
|
||||
let ncplblks = e.cplinu.iter().filter(|&&c| c).count();
|
||||
let ncplregs = (0..6).filter(|&b| e.cplstre[b] || e.cplexpstr[b] != 0).count();
|
||||
if ncplblks == 6 && ncplregs == 1 && r.bit() {
|
||||
return unsupported();
|
||||
}
|
||||
for ch in 0..nf {
|
||||
if (0..6).filter(|&b| e.chexpstr[b][ch] != 0).count() == 1 && r.bit() {
|
||||
return unsupported();
|
||||
}
|
||||
}
|
||||
if lfeon && e.lfeexpstr.iter().filter(|&&s| s != 0).count() == 1 && r.bit() {
|
||||
return unsupported();
|
||||
}
|
||||
}
|
||||
if snroffststr == 0 {
|
||||
e.frmcsnroffst = r.read(6) as i32;
|
||||
e.frmfsnroffst = r.read(4) as i32;
|
||||
}
|
||||
if transproce {
|
||||
// transient pre-noise processing (§E3.7) is not applied
|
||||
for _ in 0..nf {
|
||||
if r.bit() {
|
||||
r.skip(10 + 8); // transprocloc, transproclen
|
||||
}
|
||||
}
|
||||
}
|
||||
if spxattene {
|
||||
for ch in 0..nf {
|
||||
if r.bit() {
|
||||
e.spxatten[ch] = Some(r.read(5) as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
if bsi.numblkscod != 0 && r.bit() {
|
||||
// blkstrtinfo: (blocks - 1) × (4 + ceil(log2(words per frame))) bits (§E2.3.2.27)
|
||||
let log2 = (usize::BITS - words.saturating_sub(1).leading_zeros()) as usize;
|
||||
r.skip((nblocks - 1) * (4 + log2));
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Spectral extension parameters (§E2.2.4, §E3.6), carried from block to block of a syncframe.
|
||||
pub(crate) struct Spx {
|
||||
pub inu: bool,
|
||||
pub chinspx: [bool; 5],
|
||||
pub begf: usize,
|
||||
strtf: usize,
|
||||
/// First sub-band and one past the last (spx_begin_subbnd, spx_end_subbnd).
|
||||
begin: usize,
|
||||
end: usize,
|
||||
bndstrc: [bool; 17],
|
||||
/// Band sizes in coefficients (spxbndsztab) and their count (nspxbnds).
|
||||
sizes: [usize; 17],
|
||||
nbands: usize,
|
||||
first: [bool; 5],
|
||||
co: [[f32; 17]; 5],
|
||||
nblend: [[f32; 17]; 5],
|
||||
sblend: [[f32; 17]; 5],
|
||||
}
|
||||
|
||||
impl Spx {
|
||||
fn new() -> Spx {
|
||||
Spx {
|
||||
inu: false,
|
||||
chinspx: [false; 5],
|
||||
begf: 0,
|
||||
strtf: 0,
|
||||
begin: 0,
|
||||
end: 0,
|
||||
bndstrc: DEFAULT_SPXBNDSTRC,
|
||||
sizes: [0; 17],
|
||||
nbands: 0,
|
||||
first: [true; 5],
|
||||
co: [[0.0; 17]; 5],
|
||||
nblend: [[0.0; 17]; 5],
|
||||
sblend: [[1.0; 17]; 5],
|
||||
}
|
||||
}
|
||||
|
||||
/// First coefficient of the extension region.
|
||||
pub fn begin_bin(&self) -> usize {
|
||||
spx_band_bin(self.begin)
|
||||
}
|
||||
|
||||
/// Read the strategy and coordinates of block `blk`.
|
||||
pub(crate) fn read(&mut self, r: &mut Bits, blk: usize, acmod: usize, nf: usize) -> Result<()> {
|
||||
if blk == 0 || r.bit() {
|
||||
self.inu = r.bit();
|
||||
if self.inu {
|
||||
if acmod == 1 {
|
||||
self.chinspx = [true, false, false, false, false];
|
||||
} else {
|
||||
for ch in 0..nf {
|
||||
self.chinspx[ch] = r.bit();
|
||||
}
|
||||
}
|
||||
self.strtf = r.read(2) as usize;
|
||||
self.begf = r.read(3) as usize;
|
||||
let endf = r.read(3) as usize;
|
||||
self.begin = if self.begf < 6 { self.begf + 2 } else { self.begf * 2 - 3 };
|
||||
self.end = if endf < 3 { endf + 5 } else { endf * 2 + 3 };
|
||||
if r.bit() {
|
||||
for b in self.begin + 1..self.end {
|
||||
self.bndstrc[b] = r.bit();
|
||||
}
|
||||
}
|
||||
if self.begin >= self.end {
|
||||
return Err(Error::Invalid("spectral extension ends before it begins"));
|
||||
}
|
||||
if self.strtf >= self.begin {
|
||||
return Err(Error::Invalid("spectral extension copies from above its start"));
|
||||
}
|
||||
self.nbands = 1;
|
||||
self.sizes = [0; 17];
|
||||
self.sizes[0] = 12;
|
||||
for b in self.begin + 1..self.end {
|
||||
if self.bndstrc[b] {
|
||||
self.sizes[self.nbands - 1] += 12;
|
||||
} else {
|
||||
self.sizes[self.nbands] = 12;
|
||||
self.nbands += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.chinspx = [false; 5];
|
||||
self.first = [true; 5];
|
||||
}
|
||||
}
|
||||
if !self.inu {
|
||||
return Ok(());
|
||||
}
|
||||
for ch in 0..nf {
|
||||
if !self.chinspx[ch] {
|
||||
self.first[ch] = true;
|
||||
continue;
|
||||
}
|
||||
let coe = std::mem::replace(&mut self.first[ch], false) || r.bit();
|
||||
if !coe {
|
||||
continue;
|
||||
}
|
||||
let blend = r.read(5) as f32 / 32.0;
|
||||
let mstr = r.read(2) as i32;
|
||||
// coordinates (§E3.6.3) and blending factors (§E3.6.4.2.1)
|
||||
let mut mant = self.begin_bin() as f32;
|
||||
let end = spx_band_bin(self.end) as f32;
|
||||
for b in 0..self.nbands {
|
||||
let e = r.read(4) as i32;
|
||||
let m = r.read(2) as f32;
|
||||
let v = if e == 15 { m / 4.0 } else { (m + 4.0) / 8.0 };
|
||||
self.co[ch][b] = v * 2f32.powi(-(e + 3 * mstr));
|
||||
let size = self.sizes[b] as f32;
|
||||
let nratio = ((mant + 0.5 * size) / end - blend).clamp(0.0, 1.0);
|
||||
self.nblend[ch][b] = nratio.sqrt();
|
||||
self.sblend[ch][b] = (1.0 - nratio).sqrt();
|
||||
mant += size;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synthesize the extension region of one channel's coefficients (§E3.6.4): translate the
|
||||
/// low band up, notch-filter the borders when attenuation is on, blend with `noise` (zero
|
||||
/// mean, unit variance) scaled to the band's energy, and scale by the coordinates.
|
||||
pub(crate) fn synthesize(&self, ch: usize, atten: Option<usize>, tc: &mut [f32; 256], mut noise: impl FnMut() -> f32) {
|
||||
let copystart = spx_band_bin(self.strtf);
|
||||
let copyend = self.begin_bin();
|
||||
let sizes = &self.sizes[..self.nbands];
|
||||
// translation
|
||||
let mut wrap = [false; 17];
|
||||
let (mut copy, mut insert) = (copystart, copyend);
|
||||
for (b, &size) in sizes.iter().enumerate() {
|
||||
if copy + size > copyend {
|
||||
copy = copystart;
|
||||
wrap[b] = true;
|
||||
}
|
||||
for _ in 0..size {
|
||||
if copy == copyend {
|
||||
copy = copystart;
|
||||
}
|
||||
if let (Some(&v), true) = (tc.get(copy), insert < tc.len()) {
|
||||
tc[insert] = v;
|
||||
}
|
||||
insert += 1;
|
||||
copy += 1;
|
||||
}
|
||||
}
|
||||
// banded RMS energy of the translated coefficients
|
||||
let mut rms = [0f32; 17];
|
||||
let mut at = copyend;
|
||||
for (b, &size) in sizes.iter().enumerate() {
|
||||
let band = tc.get(at..at + size).unwrap_or_default();
|
||||
rms[b] = (band.iter().map(|v| v * v).sum::<f32>() / size as f32).sqrt();
|
||||
at += size;
|
||||
}
|
||||
// notch filter around the baseband / extension border and each wrap point
|
||||
if let Some(code) = atten {
|
||||
let g = [spx_atten(code, 0), spx_atten(code, 1), spx_atten(code, 2)];
|
||||
let mut notch = |center: usize| {
|
||||
for (k, gain) in [g[0], g[1], g[2], g[1], g[0]].into_iter().enumerate() {
|
||||
if let Some(v) = (center + k).checked_sub(2).and_then(|i| tc.get_mut(i)) {
|
||||
*v *= gain;
|
||||
}
|
||||
}
|
||||
};
|
||||
notch(copyend);
|
||||
let mut start = copyend;
|
||||
for (b, &size) in sizes.iter().enumerate() {
|
||||
if b > 0 && wrap[b] {
|
||||
notch(start);
|
||||
}
|
||||
start += size;
|
||||
}
|
||||
}
|
||||
// noise blending and scaling
|
||||
let mut at = copyend;
|
||||
for (b, &size) in sizes.iter().enumerate() {
|
||||
let nscale = rms[b] * self.nblend[ch][b];
|
||||
let sscale = self.sblend[ch][b];
|
||||
let co = self.co[ch][b] * 32.0;
|
||||
for _ in 0..size {
|
||||
let n = noise();
|
||||
if let Some(v) = tc.get_mut(at) {
|
||||
*v = (*v * sscale + n * nscale) * co;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attenuation_matches_table_e3_14() {
|
||||
// rows 0, 4, 14 and 31 as printed
|
||||
let rows: [(usize, [f64; 3]); 4] = [
|
||||
(0, [0.954841604, 0.911722489, 0.870550563]),
|
||||
(4, [0.793700526, 0.629960525, 0.500000000]),
|
||||
(14, [0.500000000, 0.250000000, 0.125000000]),
|
||||
(31, [0.227930622, 0.051952369, 0.011841536]),
|
||||
];
|
||||
for (code, want) in rows {
|
||||
for (i, w) in want.into_iter().enumerate() {
|
||||
assert!((f64::from(spx_atten(code, i)) - w).abs() < 1e-7, "{code} {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_band_structures() {
|
||||
assert_eq!(DEFAULT_CPLBNDSTRC.iter().filter(|&&b| b).count(), 8);
|
||||
assert!(DEFAULT_CPLBNDSTRC[8] && !DEFAULT_CPLBNDSTRC[9] && !DEFAULT_CPLBNDSTRC[12] && DEFAULT_CPLBNDSTRC[17]);
|
||||
assert_eq!(DEFAULT_SPXBNDSTRC.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect::<Vec<_>>(), vec![8, 10, 12, 14, 16]);
|
||||
// every frame-based strategy sends new exponents in block 0
|
||||
assert!(FRAME_EXPSTR.iter().all(|s| s[0] != 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectral_extension_translation_and_scaling() {
|
||||
// begin at sub-band 2 (bin 49), end at 5 (bin 85), copy from sub-band 0 (bin 25): three
|
||||
// 12-bin bands, the third wraps back to the copy start
|
||||
let mut s = Spx::new();
|
||||
s.inu = true;
|
||||
(s.strtf, s.begin, s.end, s.nbands) = (0, 2, 5, 3);
|
||||
s.sizes[..3].copy_from_slice(&[12, 12, 12]);
|
||||
s.co[0][..3].copy_from_slice(&[1.0 / 32.0, 0.5 / 32.0, 0.25 / 32.0]);
|
||||
s.nblend[0] = [0.0; 17];
|
||||
s.sblend[0] = [1.0; 17];
|
||||
let mut tc = [0f32; 256];
|
||||
for (i, v) in tc[25..49].iter_mut().enumerate() {
|
||||
*v = i as f32 + 1.0;
|
||||
}
|
||||
s.synthesize(0, None, &mut tc, || 0.0);
|
||||
assert_eq!(tc[49], 1.0);
|
||||
assert_eq!(tc[61], 13.0 * 0.5);
|
||||
assert_eq!(tc[73], 1.0 * 0.25);
|
||||
assert_eq!(tc[84], 12.0 * 0.25);
|
||||
assert_eq!(tc[85], 0.0);
|
||||
// pure noise: the band gets the energy of its translated coefficients
|
||||
s.nblend[0] = [1.0; 17];
|
||||
s.sblend[0] = [0.0; 17];
|
||||
s.co[0] = [1.0 / 32.0; 17];
|
||||
let mut tc2 = [0f32; 256];
|
||||
tc2[25..49].fill(2.0);
|
||||
let mut k = 0;
|
||||
s.synthesize(0, None, &mut tc2, || {
|
||||
k += 1;
|
||||
if k % 2 == 0 { 1.0 } else { -1.0 }
|
||||
});
|
||||
assert!(tc2[49..85].iter().all(|v| v.abs() == 2.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
//! Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder, written from
|
||||
//! ATSC A/52:2012 ("Digital Audio Compression Standard", §5 bit stream syntax, §6-7 decoding,
|
||||
//! Annex E for E-AC-3).
|
||||
//! Clean-room AC-3 (Dolby Digital) audio decoder, written from ATSC A/52:2012 ("Digital Audio
|
||||
//! Compression Standard", §5 bit stream syntax, §6-7 decoding).
|
||||
//!
|
||||
//! - All audio coding modes (1+1, 1/0 … 3/2) with or without LFE, 32 / 44.1 / 48 kHz, every
|
||||
//! frame size (Table 5.18).
|
||||
|
|
@ -12,15 +11,10 @@
|
|||
//! - Output: planar f32 at full scale ±1, channels in WAV / SMPTE order (L R C LFE Ls Rs); no
|
||||
//! downmix.
|
||||
//!
|
||||
//! E-AC-3 (bsid 11-16, Annex E): independent substream 0 (dependent substreams and further
|
||||
//! programs are skipped), 1 / 2 / 3 / 6 blocks per syncframe, the reduced sample rates, frame-based
|
||||
//! exponent strategies and the other frame-level syntax, and spectral extension (§E3.6). The
|
||||
//! adaptive hybrid transform and enhanced coupling are refused with [`Error::Unsupported`];
|
||||
//! transient pre-noise processing data is read and not applied.
|
||||
//! E-AC-3 (bsid 11-16) is recognised and refused.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod eac3;
|
||||
mod tables;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
|
@ -53,35 +47,12 @@ pub struct Header {
|
|||
pub bsid: u8,
|
||||
pub acmod: u8,
|
||||
pub lfeon: bool,
|
||||
/// Audio blocks (of 256 samples per channel) in the syncframe: 6 for AC-3; 1, 2, 3 or 6 for
|
||||
/// E-AC-3.
|
||||
pub blocks: u8,
|
||||
/// E-AC-3 stream type (Table E2.1): 0 independent, 1 dependent, 2 independent converted from
|
||||
/// AC-3. 0 for AC-3.
|
||||
pub strmtyp: u8,
|
||||
/// E-AC-3 substream id (0 for AC-3).
|
||||
pub substreamid: u8,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
/// Output channels (full bandwidth + LFE).
|
||||
pub fn channels(&self) -> usize {
|
||||
NFCHANS[self.acmod as usize & 7] + self.lfeon as usize
|
||||
}
|
||||
/// Samples per channel the syncframe decodes to.
|
||||
pub fn samples(&self) -> usize {
|
||||
256 * self.blocks as usize
|
||||
}
|
||||
/// Whether this is an E-AC-3 syncframe (bsid 11-16).
|
||||
pub fn is_eac3(&self) -> bool {
|
||||
self.bsid > 10
|
||||
}
|
||||
/// Whether the syncframe carries the program [`Decoder::decode`] outputs: AC-3, or E-AC-3
|
||||
/// independent substream 0 (§E2.3.1.2). [`Decoder::decode`] returns no channels for the
|
||||
/// others: dependent substreams (the channels beyond 5.1 of a 7.1 program) and further
|
||||
/// independent programs.
|
||||
pub fn is_primary(&self) -> bool {
|
||||
self.strmtyp != 1 && self.substreamid == 0
|
||||
NFCHANS[self.acmod as usize] + self.lfeon as usize
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,11 +64,8 @@ pub fn parse_header(b: &[u8]) -> Result<Header> {
|
|||
let fscod = b[4] >> 6;
|
||||
let frmsizecod = b[4] & 0x3F;
|
||||
let bsid = b[5] >> 3;
|
||||
if (11..=16).contains(&bsid) {
|
||||
return eac3::parse_header(b, bsid);
|
||||
}
|
||||
if bsid > 16 {
|
||||
return Err(Error::Unsupported(format!("bsid {bsid}")));
|
||||
if bsid > 10 {
|
||||
return Err(Error::Unsupported(format!("bsid {bsid} (E-AC-3)")));
|
||||
}
|
||||
if fscod == 3 || frmsizecod >= 38 {
|
||||
return Err(Error::Invalid("reserved fscod / frmsizecod"));
|
||||
|
|
@ -123,7 +91,7 @@ pub fn parse_header(b: &[u8]) -> Result<Header> {
|
|||
r.skip(2);
|
||||
}
|
||||
let lfeon = r.read(1) == 1;
|
||||
Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon, blocks: 6, strmtyp: 0, substreamid: 0 })
|
||||
Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon })
|
||||
}
|
||||
|
||||
/// MSB-first bit reader; reads past the end give zeros.
|
||||
|
|
@ -501,9 +469,7 @@ struct Frame {
|
|||
chincpl: [bool; 5],
|
||||
phsflginu: bool,
|
||||
cplbegf: usize,
|
||||
/// cplendf + 3: one past the last coupling sub-band (with spectral extension cplendf may be
|
||||
/// negative, §E3.3.1).
|
||||
cplend: usize,
|
||||
cplendf: usize,
|
||||
/// Coupling band of each coupling sub-band (relative to cplbegf).
|
||||
sub_to_band: [usize; 18],
|
||||
ncplbnd: usize,
|
||||
|
|
@ -534,7 +500,7 @@ impl Frame {
|
|||
chincpl: [false; 5],
|
||||
phsflginu: false,
|
||||
cplbegf: 0,
|
||||
cplend: 0,
|
||||
cplendf: 0,
|
||||
sub_to_band: [0; 18],
|
||||
ncplbnd: 0,
|
||||
cplco: [[0.0; 18]; 5],
|
||||
|
|
@ -557,7 +523,7 @@ impl Frame {
|
|||
37 + 12 * self.cplbegf
|
||||
}
|
||||
fn cplendmant(&self) -> usize {
|
||||
37 + 12 * self.cplend
|
||||
37 + 12 * (self.cplendf + 3)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -656,9 +622,6 @@ pub struct Decoder {
|
|||
delay: [[f32; 256]; 6],
|
||||
dither: u32,
|
||||
seed: u32,
|
||||
/// E-AC-3: the block state of the last syncframe (acmod, lfeon, sample rate code), whose
|
||||
/// exponents a syncframe of fewer than six blocks may reuse.
|
||||
carry: Option<(usize, bool, usize, Box<Frame>)>,
|
||||
}
|
||||
|
||||
impl Default for Decoder {
|
||||
|
|
@ -667,8 +630,7 @@ impl Default for Decoder {
|
|||
}
|
||||
}
|
||||
|
||||
/// A decoded syncframe: [`Header::samples`] samples per channel (1536 for AC-3), WAV channel
|
||||
/// order. No channels for an E-AC-3 syncframe that is not [`Header::is_primary`].
|
||||
/// A decoded syncframe: 1536 samples per channel, WAV channel order.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Decoded {
|
||||
pub header: Header,
|
||||
|
|
@ -677,7 +639,7 @@ pub struct Decoded {
|
|||
|
||||
impl Decoder {
|
||||
pub fn new() -> Decoder {
|
||||
Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678, carry: None }
|
||||
Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678 }
|
||||
}
|
||||
|
||||
/// Seed the dither generator (dither is decoder-specific noise; tests compare two seeds).
|
||||
|
|
@ -688,7 +650,6 @@ impl Decoder {
|
|||
/// Forget the overlap (before decoding from another position).
|
||||
pub fn reset(&mut self) {
|
||||
self.delay = [[0.0; 256]; 6];
|
||||
self.carry = None;
|
||||
}
|
||||
|
||||
fn dither(&mut self) -> f32 {
|
||||
|
|
@ -697,20 +658,12 @@ impl Decoder {
|
|||
((self.dither >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0) * 0.707
|
||||
}
|
||||
|
||||
/// Zero-mean, unit-variance noise for spectral extension (§E3.6.4.2.4): uniform in ±√3.
|
||||
fn noise(&mut self) -> f32 {
|
||||
self.dither() * (3f32.sqrt() / 0.707)
|
||||
}
|
||||
|
||||
/// Decode one syncframe.
|
||||
pub fn decode(&mut self, frame: &[u8]) -> Result<Decoded> {
|
||||
let h = parse_header(frame)?;
|
||||
if frame.len() < h.frame_bytes {
|
||||
return Err(Error::Invalid("truncated syncframe"));
|
||||
}
|
||||
if h.is_eac3() {
|
||||
return self.decode_eac3(&frame[..h.frame_bytes], h);
|
||||
}
|
||||
let fscod = (frame[4] >> 6) as usize;
|
||||
// Dither is reseeded per syncframe from its CRC words (plus the configured seed), so a
|
||||
// frame decodes to the same samples however it was reached (seeking, render caches).
|
||||
|
|
@ -755,83 +708,39 @@ impl Decoder {
|
|||
let l = r.read(6) as usize;
|
||||
r.skip((l + 1) * 8);
|
||||
}
|
||||
let mut f = Frame::new(NFCHANS[acmod], fscod);
|
||||
let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, 6, None)?;
|
||||
Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) })
|
||||
}
|
||||
|
||||
/// Decode one E-AC-3 syncframe (`frame` is exactly the syncframe).
|
||||
fn decode_eac3(&mut self, frame: &[u8], h: Header) -> Result<Decoded> {
|
||||
if !h.is_primary() {
|
||||
return Ok(Decoded { header: h, channels: Vec::new() });
|
||||
}
|
||||
// E-AC-3 has a single CRC, at the end (§E2.2.6): reseed the dither from the last words
|
||||
let n = frame.len();
|
||||
let tail = frame.get(n.saturating_sub(4)..).and_then(|t| <[u8; 4]>::try_from(t).ok()).unwrap_or_default();
|
||||
self.dither = self.seed ^ u32::from_be_bytes(tail).wrapping_mul(0x9E37_79B9);
|
||||
let mut r = Bits::new(frame);
|
||||
let bsi = eac3::read_bsi(&mut r)?;
|
||||
let (acmod, lfeon) = (h.acmod as usize & 7, h.lfeon);
|
||||
let nblocks = h.blocks as usize;
|
||||
let mut e = eac3::FrameInfo::read(&mut r, &bsi, acmod, lfeon, nblocks, n / 2)?;
|
||||
let mut f = match self.carry.take() {
|
||||
Some((a, l, fs, f)) if (a, l, fs) == (acmod, lfeon, bsi.fscod) => f,
|
||||
_ => Box::new(Frame::new(NFCHANS[acmod], bsi.fscod)),
|
||||
};
|
||||
let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, nblocks, Some(&mut e))?;
|
||||
self.carry = Some((acmod, lfeon, bsi.fscod, f));
|
||||
Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) })
|
||||
}
|
||||
|
||||
/// Decode `nblocks` audio blocks and synthesize them (inverse transform, overlap-add), in coded
|
||||
/// channel order with the LFE last.
|
||||
fn blocks(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, nblocks: usize, mut x: Option<&mut eac3::FrameInfo>) -> Result<Vec<Vec<f32>>> {
|
||||
let nfchans = f.nfchans;
|
||||
let nfchans = NFCHANS[acmod];
|
||||
let mut f = Frame::new(nfchans, fscod);
|
||||
let nout = nfchans + lfeon as usize;
|
||||
let mut pcm: Vec<Vec<f32>> = vec![Vec::with_capacity(256 * nblocks); nout];
|
||||
for blk in 0..nblocks {
|
||||
let mut pcm: Vec<Vec<f32>> = vec![Vec::with_capacity(1536); nout];
|
||||
for blk in 0..6 {
|
||||
let mut coefs = [[0f32; 256]; 6];
|
||||
self.audio_block(r, f, acmod, lfeon, blk, &mut coefs, x.as_deref_mut())?;
|
||||
self.audio_block(&mut r, &mut f, acmod, lfeon, blk, &mut coefs)?;
|
||||
if r.overrun() {
|
||||
return Err(Error::Invalid("audio block runs past the frame"));
|
||||
}
|
||||
// inverse transform, overlap-add
|
||||
let mut buf = [0f32; 512];
|
||||
for (ch, out) in pcm.iter_mut().enumerate() {
|
||||
for ch in 0..nout {
|
||||
let src = if ch < nfchans { ch } else { LFE };
|
||||
let short = ch < nfchans && f.blksw[ch];
|
||||
imdct(&coefs[src], short, &mut buf);
|
||||
let d = &mut self.delay[src];
|
||||
for n in 0..256 {
|
||||
out.push(2.0 * (buf[n] + d[n]));
|
||||
pcm[ch].push(2.0 * (buf[n] + d[n]));
|
||||
d[n] = buf[256 + n];
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(pcm)
|
||||
Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn audio_block(
|
||||
&mut self,
|
||||
r: &mut Bits,
|
||||
f: &mut Frame,
|
||||
acmod: usize,
|
||||
lfeon: bool,
|
||||
blk: usize,
|
||||
coefs: &mut [[f32; 256]; 6],
|
||||
mut x: Option<&mut eac3::FrameInfo>,
|
||||
) -> Result<()> {
|
||||
fn audio_block(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, blk: usize, coefs: &mut [[f32; 256]; 6]) -> Result<()> {
|
||||
let nf = f.nfchans;
|
||||
let eac3 = x.is_some();
|
||||
match x.as_deref() {
|
||||
Some(e) if !e.blkswe => f.blksw = [false; 5],
|
||||
_ => (0..nf).for_each(|ch| f.blksw[ch] = r.bit()),
|
||||
for ch in 0..nf {
|
||||
f.blksw[ch] = r.bit();
|
||||
}
|
||||
match x.as_deref() {
|
||||
// dither on when the flags are not sent (§E2.2.4)
|
||||
Some(e) if !e.dithflage => f.dithflag = [true; 5],
|
||||
_ => (0..nf).for_each(|ch| f.dithflag[ch] = r.bit()),
|
||||
for ch in 0..nf {
|
||||
f.dithflag[ch] = r.bit();
|
||||
}
|
||||
for k in 0..if acmod == 0 { 2 } else { 1 } {
|
||||
if r.bit() {
|
||||
|
|
@ -840,62 +749,26 @@ impl Decoder {
|
|||
f.dynrng[k] = 1.0;
|
||||
}
|
||||
}
|
||||
// E-AC-3 spectral extension strategy and coordinates
|
||||
if let Some(e) = x.as_deref_mut() {
|
||||
e.spx.read(r, blk, acmod, nf)?;
|
||||
}
|
||||
let spx = x.as_deref().map(|e| &e.spx).filter(|s| s.inu);
|
||||
let spxbegf = spx.map(|s| s.begf);
|
||||
let spx_begin = spx.map(|s| s.begin_bin());
|
||||
let spx_chans = spx.map(|s| s.chinspx).unwrap_or([false; 5]);
|
||||
// coupling strategy
|
||||
let strategy = match x.as_deref() {
|
||||
Some(e) => e.cplstre[blk].then_some(e.cplinu[blk]),
|
||||
None => r.bit().then(|| r.bit()),
|
||||
};
|
||||
if let Some(inu) = strategy {
|
||||
f.cplinu = inu;
|
||||
if r.bit() {
|
||||
f.cplinu = r.bit();
|
||||
if f.cplinu {
|
||||
if eac3 && r.bit() {
|
||||
return Err(Error::Unsupported("E-AC-3 enhanced coupling".into()));
|
||||
}
|
||||
if eac3 && acmod == 2 {
|
||||
f.chincpl = [true, true, false, false, false];
|
||||
} else {
|
||||
for ch in 0..nf {
|
||||
f.chincpl[ch] = r.bit();
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.chincpl[ch] = r.bit();
|
||||
}
|
||||
if acmod == 2 {
|
||||
f.phsflginu = r.bit();
|
||||
}
|
||||
f.cplbegf = r.read(4) as usize;
|
||||
f.cplend = match spxbegf {
|
||||
// the coupling region ends where spectral extension begins (§E2.2.4)
|
||||
Some(b) if b < 6 => b + 1,
|
||||
Some(b) => 2 * b - 4,
|
||||
None => r.read(4) as usize + 3,
|
||||
};
|
||||
if f.cplend <= f.cplbegf {
|
||||
f.cplendf = r.read(4) as usize;
|
||||
if f.cplendf + 3 <= f.cplbegf {
|
||||
return Err(Error::Invalid("coupling end below start"));
|
||||
}
|
||||
// coupling band structure, by absolute sub-band
|
||||
let mut strc = [false; 18];
|
||||
match x.as_deref_mut() {
|
||||
Some(e) => {
|
||||
if r.bit() {
|
||||
for s in f.cplbegf + 1..f.cplend {
|
||||
e.cplbndstrc[s] = r.bit();
|
||||
}
|
||||
}
|
||||
strc = e.cplbndstrc;
|
||||
}
|
||||
None => (f.cplbegf + 1..f.cplend).for_each(|s| strc[s] = r.bit()),
|
||||
}
|
||||
let nsub = 3 + f.cplendf - f.cplbegf;
|
||||
let mut band = 0;
|
||||
f.sub_to_band[0] = 0;
|
||||
for s in 1..f.cplend - f.cplbegf {
|
||||
if !strc[f.cplbegf + s] {
|
||||
for s in 1..nsub {
|
||||
if !r.bit() {
|
||||
band += 1;
|
||||
}
|
||||
f.sub_to_band[s] = band;
|
||||
|
|
@ -903,33 +776,15 @@ impl Decoder {
|
|||
f.ncplbnd = band + 1;
|
||||
} else {
|
||||
f.chincpl = [false; 5];
|
||||
if let Some(e) = x.as_deref_mut() {
|
||||
e.firstcplcos = [true; 5];
|
||||
e.firstcplleak = true;
|
||||
f.phsflginu = false;
|
||||
}
|
||||
}
|
||||
} else if blk == 0 && !eac3 {
|
||||
} else if blk == 0 {
|
||||
return Err(Error::Invalid("no coupling strategy in block 0"));
|
||||
}
|
||||
// coupling coordinates
|
||||
if f.cplinu {
|
||||
let mut any = false;
|
||||
for ch in 0..nf {
|
||||
if !f.chincpl[ch] {
|
||||
if let Some(e) = x.as_deref_mut() {
|
||||
e.firstcplcos[ch] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let coe = match x.as_deref_mut() {
|
||||
Some(e) if e.firstcplcos[ch] => {
|
||||
e.firstcplcos[ch] = false;
|
||||
true
|
||||
}
|
||||
_ => r.bit(),
|
||||
};
|
||||
if coe {
|
||||
if f.chincpl[ch] && r.bit() {
|
||||
any = true;
|
||||
let mstr = r.read(2) as i32;
|
||||
for bnd in 0..f.ncplbnd {
|
||||
|
|
@ -937,7 +792,7 @@ impl Decoder {
|
|||
let m = r.read(4) as f32;
|
||||
let v = if e == 15 { m / 16.0 } else { (m + 16.0) / 32.0 };
|
||||
let co = v * 2f32.powi(-(e + 3 * mstr));
|
||||
for s in 0..(f.cplend - f.cplbegf) {
|
||||
for s in 0..(3 + f.cplendf - f.cplbegf) {
|
||||
if f.sub_to_band[s] == bnd {
|
||||
f.cplco[ch][s] = co;
|
||||
}
|
||||
|
|
@ -948,7 +803,7 @@ impl Decoder {
|
|||
if acmod == 2 && f.phsflginu && any {
|
||||
for bnd in 0..f.ncplbnd {
|
||||
let flag = r.bit();
|
||||
for s in 0..(f.cplend - f.cplbegf) {
|
||||
for s in 0..(3 + f.cplendf - f.cplbegf) {
|
||||
if f.sub_to_band[s] == bnd {
|
||||
f.phsflg[s] = flag;
|
||||
}
|
||||
|
|
@ -956,55 +811,34 @@ impl Decoder {
|
|||
}
|
||||
}
|
||||
}
|
||||
// rematrixing (E-AC-3 always sends the flags in block 0)
|
||||
if acmod == 2 && ((eac3 && blk == 0) || r.bit()) {
|
||||
let n = if f.cplinu {
|
||||
match f.cplbegf {
|
||||
0 => 2,
|
||||
1 | 2 => 3,
|
||||
_ => 4,
|
||||
}
|
||||
// rematrixing
|
||||
if acmod == 2 && r.bit() {
|
||||
let n = if !f.cplinu || f.cplbegf > 2 {
|
||||
4
|
||||
} else if f.cplbegf > 0 {
|
||||
3
|
||||
} else {
|
||||
match spxbegf {
|
||||
Some(b) if b < 2 => 3,
|
||||
_ => 4,
|
||||
}
|
||||
2
|
||||
};
|
||||
f.rematflg = [false; 4];
|
||||
for k in 0..n {
|
||||
f.rematflg[k] = r.bit();
|
||||
}
|
||||
}
|
||||
// exponent strategies (E-AC-3: from the audio frame)
|
||||
match x.as_deref() {
|
||||
Some(e) => {
|
||||
if f.cplinu {
|
||||
f.expstr[CPL] = e.cplexpstr[blk];
|
||||
}
|
||||
f.expstr[..nf].copy_from_slice(&e.chexpstr[blk][..nf]);
|
||||
if lfeon {
|
||||
f.expstr[LFE] = e.lfeexpstr[blk];
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if f.cplinu {
|
||||
f.expstr[CPL] = r.read(2) as u8;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.expstr[ch] = r.read(2) as u8;
|
||||
}
|
||||
if lfeon {
|
||||
f.expstr[LFE] = r.read(1) as u8;
|
||||
}
|
||||
}
|
||||
// exponent strategies
|
||||
if f.cplinu {
|
||||
f.expstr[CPL] = r.read(2) as u8;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.expstr[ch] = r.read(2) as u8;
|
||||
}
|
||||
if lfeon {
|
||||
f.expstr[LFE] = r.read(1) as u8;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
if f.expstr[ch] != 0 {
|
||||
if f.chincpl[ch] && f.cplinu {
|
||||
f.endmant[ch] = f.cplstrtmant();
|
||||
} else if let (true, Some(begin)) = (spx_chans[ch], spx_begin) {
|
||||
// §E3.3.3: the channel ends where spectral extension begins
|
||||
f.endmant[ch] = begin;
|
||||
} else {
|
||||
let bw = r.read(6) as usize;
|
||||
if bw > 60 {
|
||||
|
|
@ -1036,105 +870,40 @@ impl Decoder {
|
|||
decode_exponents(r, abs, 2, 1, &mut f.exps[LFE], 0, false)?;
|
||||
}
|
||||
// bit allocation parametric information
|
||||
match x.as_deref() {
|
||||
Some(e) if !e.bamode => {
|
||||
// §E2.2.4 defaults
|
||||
(f.g.sdcycod, f.g.fdcycod, f.g.sgaincod, f.g.dbpbcod, f.g.floorcod) = (2, 1, 1, 2, 7);
|
||||
}
|
||||
_ => {
|
||||
if r.bit() {
|
||||
f.g.sdcycod = r.read(2) as usize;
|
||||
f.g.fdcycod = r.read(2) as usize;
|
||||
f.g.sgaincod = r.read(2) as usize;
|
||||
f.g.dbpbcod = r.read(2) as usize;
|
||||
f.g.floorcod = r.read(3) as usize;
|
||||
} else if blk == 0 && !eac3 {
|
||||
return Err(Error::Invalid("no bit allocation information in block 0"));
|
||||
}
|
||||
}
|
||||
if r.bit() {
|
||||
f.g.sdcycod = r.read(2) as usize;
|
||||
f.g.fdcycod = r.read(2) as usize;
|
||||
f.g.sgaincod = r.read(2) as usize;
|
||||
f.g.dbpbcod = r.read(2) as usize;
|
||||
f.g.floorcod = r.read(3) as usize;
|
||||
} else if blk == 0 {
|
||||
return Err(Error::Invalid("no bit allocation information in block 0"));
|
||||
}
|
||||
match x.as_deref() {
|
||||
Some(e) => {
|
||||
// SNR offset strategies (Table E2.9), fast gain codes
|
||||
let sets = [CPL, 0, 1, 2, 3, 4, LFE];
|
||||
match e.snroffststr {
|
||||
0 => {
|
||||
f.csnroffst = e.frmcsnroffst;
|
||||
sets.iter().for_each(|&c| f.fsnroffst[c] = e.frmfsnroffst);
|
||||
}
|
||||
s if blk == 0 || r.bit() => {
|
||||
f.csnroffst = r.read(6) as i32;
|
||||
if s == 1 {
|
||||
let v = r.read(4) as i32;
|
||||
sets.iter().for_each(|&c| f.fsnroffst[c] = v);
|
||||
} else {
|
||||
if f.cplinu {
|
||||
f.fsnroffst[CPL] = r.read(4) as i32;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.fsnroffst[ch] = r.read(4) as i32;
|
||||
}
|
||||
if lfeon {
|
||||
f.fsnroffst[LFE] = r.read(4) as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if e.frmfgaincode && r.bit() {
|
||||
if f.cplinu {
|
||||
f.fgaincod[CPL] = r.read(3) as usize;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.fgaincod[ch] = r.read(3) as usize;
|
||||
}
|
||||
if lfeon {
|
||||
f.fgaincod[LFE] = r.read(3) as usize;
|
||||
}
|
||||
} else {
|
||||
f.fgaincod = [4; 7];
|
||||
}
|
||||
if e.strmtyp == 0 && r.bit() {
|
||||
r.skip(10); // convsnroffst
|
||||
}
|
||||
if r.bit() {
|
||||
f.csnroffst = r.read(6) as i32;
|
||||
if f.cplinu {
|
||||
f.fsnroffst[CPL] = r.read(4) as i32;
|
||||
f.fgaincod[CPL] = r.read(3) as usize;
|
||||
}
|
||||
None => {
|
||||
if r.bit() {
|
||||
f.csnroffst = r.read(6) as i32;
|
||||
if f.cplinu {
|
||||
f.fsnroffst[CPL] = r.read(4) as i32;
|
||||
f.fgaincod[CPL] = r.read(3) as usize;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.fsnroffst[ch] = r.read(4) as i32;
|
||||
f.fgaincod[ch] = r.read(3) as usize;
|
||||
}
|
||||
if lfeon {
|
||||
f.fsnroffst[LFE] = r.read(4) as i32;
|
||||
f.fgaincod[LFE] = r.read(3) as usize;
|
||||
}
|
||||
} else if blk == 0 {
|
||||
return Err(Error::Invalid("no SNR offsets in block 0"));
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.fsnroffst[ch] = r.read(4) as i32;
|
||||
f.fgaincod[ch] = r.read(3) as usize;
|
||||
}
|
||||
if lfeon {
|
||||
f.fsnroffst[LFE] = r.read(4) as i32;
|
||||
f.fgaincod[LFE] = r.read(3) as usize;
|
||||
}
|
||||
} else if blk == 0 {
|
||||
return Err(Error::Invalid("no SNR offsets in block 0"));
|
||||
}
|
||||
if f.cplinu {
|
||||
let leak = match x.as_deref_mut() {
|
||||
Some(e) if e.firstcplleak => {
|
||||
e.firstcplleak = false;
|
||||
true
|
||||
}
|
||||
_ => r.bit(),
|
||||
};
|
||||
if leak {
|
||||
f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768);
|
||||
}
|
||||
if f.cplinu && r.bit() {
|
||||
f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768);
|
||||
}
|
||||
// delta bit allocation
|
||||
if blk == 0 {
|
||||
f.deltbae = [2; 7];
|
||||
}
|
||||
if x.as_deref().is_none_or(|e| e.dbaflde) && r.bit() {
|
||||
if r.bit() {
|
||||
if f.cplinu {
|
||||
f.deltbae[CPL] = r.read(2) as u8;
|
||||
}
|
||||
|
|
@ -1152,7 +921,7 @@ impl Decoder {
|
|||
}
|
||||
}
|
||||
// skip field
|
||||
if x.as_deref().is_none_or(|e| e.skipflde) && r.bit() {
|
||||
if r.bit() {
|
||||
let l = r.read(9) as usize;
|
||||
r.skip(l * 8);
|
||||
}
|
||||
|
|
@ -1245,16 +1014,6 @@ impl Decoder {
|
|||
}
|
||||
}
|
||||
}
|
||||
// spectral extension: synthesize the high band of each channel from its low band
|
||||
if let Some(e) = x.as_deref()
|
||||
&& e.spx.inu
|
||||
{
|
||||
for ch in 0..nf {
|
||||
if e.spx.chinspx[ch] {
|
||||
e.spx.synthesize(ch, e.spxatten[ch], &mut coefs[ch], || self.noise());
|
||||
}
|
||||
}
|
||||
}
|
||||
// dynamic range
|
||||
for ch in 0..nf {
|
||||
let g = if acmod == 0 && ch == 1 { f.dynrng[1] } else { f.dynrng[0] };
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ fn headers_and_gains() {
|
|||
let b = [0x0B, 0x77, 0, 0, 30, 8 << 3, 0b1110_0001, 0b0100_0000];
|
||||
let h = parse_header(&b).unwrap();
|
||||
assert_eq!((h.sample_rate, h.bitrate_kbps, h.frame_bytes, h.acmod, h.lfeon, h.channels()), (48_000, 448, 1792, 7, true, 6));
|
||||
assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 17 << 3, 0, 0]), Err(Error::Unsupported("bsid 17".into())));
|
||||
assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 16 << 3, 0, 0]), Err(Error::Unsupported("bsid 16 (E-AC-3)".into())));
|
||||
assert_eq!(dynrng_gain(0), 1.0);
|
||||
assert!((dynrng_gain(0b1110_0000) - 0.5).abs() < 1e-7);
|
||||
assert!((dynrng_gain(0b0111_1111) - 16.0 * 63.0 / 64.0).abs() < 1e-5);
|
||||
|
|
@ -96,176 +96,3 @@ fn garbage_never_panics() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MSB-first bit writer for synthetic E-AC-3 syncframes.
|
||||
#[derive(Default)]
|
||||
struct W {
|
||||
b: Vec<u8>,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl W {
|
||||
fn put(&mut self, bits: u32, v: u32) {
|
||||
for i in (0..bits).rev() {
|
||||
if self.n.is_multiple_of(8) {
|
||||
self.b.push(0);
|
||||
}
|
||||
let bit = ((v >> i) & 1) as u8;
|
||||
*self.b.last_mut().unwrap() |= bit << (7 - self.n % 8);
|
||||
self.n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An E-AC-3 syncframe of `words` 16-bit words: syncinfo and a bsi with no optional metadata
|
||||
/// (fscod < 3 with `numblkscod`), then `audio` writes the rest; zero padded.
|
||||
fn eac3_frame(strmtyp: u32, substreamid: u32, words: u32, numblkscod: u32, acmod: u32, audio: impl FnOnce(&mut W)) -> Vec<u8> {
|
||||
let mut w = W::default();
|
||||
w.put(16, 0x0B77);
|
||||
w.put(2, strmtyp);
|
||||
w.put(3, substreamid);
|
||||
w.put(11, words - 1);
|
||||
w.put(2, 0); // 48 kHz
|
||||
w.put(2, numblkscod);
|
||||
w.put(3, acmod);
|
||||
w.put(1, 0); // lfeon
|
||||
w.put(5, 16); // bsid
|
||||
w.put(5, 31); // dialnorm
|
||||
w.put(1, 0); // compre
|
||||
if strmtyp == 1 {
|
||||
w.put(1, 0); // chanmape
|
||||
}
|
||||
w.put(2, 0); // mixmdate, infomdate
|
||||
if strmtyp == 0 && numblkscod != 3 {
|
||||
w.put(1, 0); // convsync
|
||||
}
|
||||
w.put(1, 0); // addbsie
|
||||
audio(&mut w);
|
||||
let mut b = w.b;
|
||||
b.resize(words as usize * 2, 0);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_headers() {
|
||||
let f = eac3_frame(0, 0, 384, 3, 2, |_| {});
|
||||
let h = parse_header(&f).unwrap();
|
||||
assert_eq!((h.sample_rate, h.blocks, h.samples(), h.frame_bytes, h.bitrate_kbps, h.channels()), (48_000, 6, 1536, 768, 192, 2));
|
||||
assert!(h.is_eac3() && h.is_primary());
|
||||
// one block per syncframe: 256 samples
|
||||
let h = parse_header(&eac3_frame(0, 0, 100, 0, 7, |_| {})).unwrap();
|
||||
assert_eq!((h.blocks, h.samples(), h.channels(), h.bitrate_kbps), (1, 256, 5, 300));
|
||||
// reduced sample rate (fscod 3, fscod2 1): six blocks at 22.05 kHz
|
||||
let mut f = eac3_frame(0, 0, 384, 0, 2, |_| {});
|
||||
f[4] = 0b1101_0100;
|
||||
let h = parse_header(&f).unwrap();
|
||||
assert_eq!((h.sample_rate, h.blocks, h.acmod), (22_050, 6, 2));
|
||||
f[4] = 0b1111_0100;
|
||||
assert_eq!(parse_header(&f), Err(Error::Invalid("reserved fscod2")));
|
||||
// a dependent substream and a second independent program are recognised and skipped
|
||||
let mut d = Decoder::new();
|
||||
for (strmtyp, id) in [(1, 0), (0, 1)] {
|
||||
let f = eac3_frame(strmtyp, id, 64, 3, 2, |_| {});
|
||||
let out = d.decode(&f).unwrap();
|
||||
assert!(!out.header.is_primary() && out.channels.is_empty());
|
||||
}
|
||||
assert_eq!(parse_header(&eac3_frame(3, 0, 64, 3, 2, |_| {})), Err(Error::Unsupported("reserved E-AC-3 stream type 3".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_tools_we_do_not_decode_are_refused() {
|
||||
// mono, six blocks, AHT enabled and used: chexpstr D15 then reuse, so chahtinu is sent
|
||||
let aht = eac3_frame(0, 0, 64, 3, 1, |w| {
|
||||
w.put(1, 1); // expstre
|
||||
w.put(1, 1); // ahte
|
||||
w.put(2, 0); // snroffststr
|
||||
w.put(8, 0); // transproce … spxattene
|
||||
w.put(2, 1); // chexpstr[0] = D15
|
||||
w.put(10, 0); // chexpstr[1..6] = reuse
|
||||
w.put(5, 0); // convexpstr
|
||||
w.put(1, 1); // chahtinu
|
||||
});
|
||||
assert_eq!(Decoder::new().decode(&aht).unwrap_err(), Error::Unsupported("E-AC-3 adaptive hybrid transform".into()));
|
||||
// stereo with coupling in block 0 using enhanced coupling
|
||||
let ecpl = eac3_frame(0, 0, 64, 3, 2, |w| {
|
||||
w.put(1, 1); // expstre
|
||||
w.put(1, 0); // ahte
|
||||
w.put(2, 0); // snroffststr
|
||||
w.put(8, 0);
|
||||
w.put(1, 1); // cplinu[0]
|
||||
w.put(5, 0); // cplstre[1..6]
|
||||
w.put(6, 0b01_01_01); // block 0: cplexpstr, chexpstr × 2
|
||||
w.put(30, 0); // blocks 1-5: reuse
|
||||
w.put(10, 0); // convexpstr
|
||||
w.put(10, 0); // frmcsnroffst, frmfsnroffst
|
||||
w.put(1, 0); // blkstrtinfoe
|
||||
w.put(1, 0); // dynrnge
|
||||
w.put(1, 0); // spxinu
|
||||
w.put(1, 1); // ecplinu
|
||||
});
|
||||
assert_eq!(Decoder::new().decode(&ecpl).unwrap_err(), Error::Unsupported("E-AC-3 enhanced coupling".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_single_block_frames_reuse_exponents_across_syncframes() {
|
||||
// mono, one block per syncframe; the first sends exponents (all 10, bandwidth code 0), the
|
||||
// second reuses them. Zero SNR offsets allocate no bits: every coefficient is dither.
|
||||
let frame = |new_exps: bool| {
|
||||
eac3_frame(0, 0, 32, 0, 1, |w| {
|
||||
// (expstre and ahte are not sent with fewer than six blocks)
|
||||
w.put(2, 0); // snroffststr
|
||||
w.put(8, 0); // transproce … spxattene
|
||||
w.put(2, new_exps as u32); // chexpstr[0]: D15 or reuse
|
||||
w.put(1, 0); // convexpstre
|
||||
w.put(10, 0); // frmcsnroffst, frmfsnroffst
|
||||
w.put(1, 0); // dynrnge
|
||||
w.put(1, 0); // spxinu
|
||||
if new_exps {
|
||||
w.put(6, 0); // chbwcod → 73 coefficients
|
||||
w.put(4, 10); // absolute exponent
|
||||
for _ in 0..24 {
|
||||
w.put(7, 62); // three zero deltas
|
||||
}
|
||||
w.put(2, 0); // gainrng
|
||||
}
|
||||
w.put(1, 0); // convsnroffste
|
||||
})
|
||||
};
|
||||
let (first, second) = (frame(true), frame(false));
|
||||
let mut d = Decoder::new();
|
||||
let a = d.decode(&first).unwrap();
|
||||
assert_eq!((a.header.samples(), a.channels.len(), a.channels[0].len()), (256, 1, 256));
|
||||
let b = d.decode(&second).unwrap();
|
||||
let peak = |c: &[f32]| c.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!(peak(&b.channels[0]) > 1e-5 && peak(&b.channels[0]) < 0.1, "{}", peak(&b.channels[0]));
|
||||
// after a reset there are no exponents to reuse: no coefficients, silence
|
||||
d.reset();
|
||||
let c = d.decode(&second).unwrap();
|
||||
assert_eq!(peak(&c.channels[0]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_garbage_never_panics() {
|
||||
let mut d = Decoder::new();
|
||||
let mut x = 0x5EEDu32;
|
||||
for len in [8usize, 40, 300, 1500, 4096] {
|
||||
for _ in 0..300 {
|
||||
let mut f: Vec<u8> = (0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x as u8
|
||||
})
|
||||
.collect();
|
||||
f[0] = 0x0B;
|
||||
f[1] = 0x77;
|
||||
// independent substream 0, frmsiz within the buffer, bsid 11-16
|
||||
let words = (len / 2).clamp(4, 2048) - 1;
|
||||
f[2] = (words >> 8) as u8 & 7;
|
||||
f[3] = words as u8;
|
||||
f[5] = (f[5] & 7) | ((11 + x % 6) as u8) << 3;
|
||||
let _ = d.decode(&f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! AC-3 and E-AC-3 against ffmpeg: ffmpeg's AC-3 and E-AC-3 encoders write raw syncframes from
|
||||
//! generated tones and noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across
|
||||
//! AC-3 against ffmpeg: ffmpeg's AC-3 encoder writes raw syncframes from generated tones and
|
||||
//! noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across
|
||||
//! decoders (zero-bit mantissas are filled with decoder-specific dither, §7.3.4; float vs fixed
|
||||
//! arithmetic), so the criterion is SNR against ffmpeg's decode.
|
||||
|
||||
|
|
@ -8,8 +8,6 @@ use std::process::{Command, Stdio};
|
|||
|
||||
struct Spec {
|
||||
name: &'static str,
|
||||
/// ffmpeg encoder and raw muxer: "ac3" or "eac3".
|
||||
codec: &'static str,
|
||||
input: &'static str,
|
||||
rate: u32,
|
||||
channels: u32,
|
||||
|
|
@ -21,7 +19,6 @@ struct Spec {
|
|||
const SPECS: &[Spec] = &[
|
||||
Spec {
|
||||
name: "ac3_stereo_192k",
|
||||
codec: "ac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000",
|
||||
rate: 48_000,
|
||||
channels: 2,
|
||||
|
|
@ -31,26 +28,16 @@ const SPECS: &[Spec] = &[
|
|||
// low bitrate stereo: coupling and rematrixing
|
||||
Spec {
|
||||
name: "ac3_stereo_96k_coupling",
|
||||
codec: "ac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100",
|
||||
rate: 44_100,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "96k", "-channel_coupling", "1"],
|
||||
min_snr: 40.0,
|
||||
},
|
||||
Spec {
|
||||
name: "ac3_mono_32k",
|
||||
codec: "ac3",
|
||||
input: "sine=frequency=1000:sample_rate=32000",
|
||||
rate: 32_000,
|
||||
channels: 1,
|
||||
enc: &["-b:a", "96k"],
|
||||
min_snr: 60.0,
|
||||
},
|
||||
Spec { name: "ac3_mono_32k", input: "sine=frequency=1000:sample_rate=32000", rate: 32_000, channels: 1, enc: &["-b:a", "96k"], min_snr: 60.0 },
|
||||
// 5.1 with LFE
|
||||
Spec {
|
||||
name: "ac3_51_448k",
|
||||
codec: "ac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1",
|
||||
rate: 48_000,
|
||||
channels: 6,
|
||||
|
|
@ -60,86 +47,22 @@ const SPECS: &[Spec] = &[
|
|||
// noise (wideband, many zero-bit mantissas): the SNR is limited by dither
|
||||
Spec {
|
||||
name: "ac3_noise_stereo",
|
||||
codec: "ac3",
|
||||
input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0",
|
||||
rate: 48_000,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "128k"],
|
||||
min_snr: 18.0,
|
||||
},
|
||||
// E-AC-3 (Annex E): ffmpeg writes six-block syncframes with frame-based exponent strategies
|
||||
Spec {
|
||||
name: "eac3_stereo_192k",
|
||||
codec: "eac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000",
|
||||
rate: 48_000,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "192k"],
|
||||
min_snr: 60.0,
|
||||
},
|
||||
// low bitrate stereo: coupling (with the default band structure) and rematrixing
|
||||
Spec {
|
||||
name: "eac3_stereo_64k_coupling",
|
||||
codec: "eac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100",
|
||||
rate: 44_100,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "64k", "-channel_coupling", "1"],
|
||||
min_snr: 35.0,
|
||||
},
|
||||
Spec {
|
||||
name: "eac3_mono_32k",
|
||||
codec: "eac3",
|
||||
input: "sine=frequency=1000:sample_rate=32000",
|
||||
rate: 32_000,
|
||||
channels: 1,
|
||||
enc: &["-b:a", "64k"],
|
||||
min_snr: 60.0,
|
||||
},
|
||||
// 5.1 with LFE and mixing / informational metadata in the bsi
|
||||
Spec {
|
||||
name: "eac3_51_384k_metadata",
|
||||
codec: "eac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1",
|
||||
rate: 48_000,
|
||||
channels: 6,
|
||||
enc: &[
|
||||
"-b:a",
|
||||
"384k",
|
||||
"-dmix_mode",
|
||||
"loro",
|
||||
"-ltrt_cmixlev",
|
||||
"0.707",
|
||||
"-loro_surmixlev",
|
||||
"0.5",
|
||||
"-dsurex_mode",
|
||||
"on",
|
||||
"-mixing_level",
|
||||
"100",
|
||||
"-room_type",
|
||||
"small",
|
||||
],
|
||||
min_snr: 55.0,
|
||||
},
|
||||
Spec {
|
||||
name: "eac3_noise_stereo",
|
||||
codec: "eac3",
|
||||
input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0",
|
||||
rate: 48_000,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "96k", "-dsur_mode", "on", "-dheadphone_mode", "off"],
|
||||
min_snr: 18.0,
|
||||
},
|
||||
];
|
||||
|
||||
fn make(ff: &Path, s: &Spec) -> Option<PathBuf> {
|
||||
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec));
|
||||
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name));
|
||||
filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
let st = Command::new(ff)
|
||||
.args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", s.input, "-t", "1"])
|
||||
.args(["-c:a", s.codec])
|
||||
.args(["-c:a", "ac3"])
|
||||
.args(s.enc)
|
||||
.args(["-f", s.codec])
|
||||
.args(["-f", "ac3"])
|
||||
.arg(tmp)
|
||||
.stdin(Stdio::null())
|
||||
.status();
|
||||
|
|
@ -171,7 +94,6 @@ fn ffmpeg_encoded_streams_decode() {
|
|||
for fr in frames(&data) {
|
||||
let d = dec.decode(fr).unwrap();
|
||||
assert_eq!((d.header.sample_rate, d.channels.len() as u32), (s.rate, s.channels), "{}", s.name);
|
||||
assert_eq!(d.header.is_eac3(), s.codec == "eac3", "{}", s.name);
|
||||
for (o, c) in ours.iter_mut().zip(d.channels) {
|
||||
o.extend(c);
|
||||
}
|
||||
|
|
@ -214,52 +136,12 @@ fn ffmpeg_encoded_streams_decode() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Damaged E-AC-3 syncframes (bit flips, truncation, corrupt sizes) give errors or noise, never
|
||||
/// a panic.
|
||||
#[test]
|
||||
fn damaged_eac3_frames_never_panic() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let mut x = 0x00C0_FFEEu32;
|
||||
let mut rnd = move || {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x
|
||||
};
|
||||
for s in SPECS.iter().filter(|s| s.codec == "eac3") {
|
||||
let f = make(&ff, s).unwrap_or_else(|| panic!("could not generate {}", s.name));
|
||||
let data = std::fs::read(&f).unwrap();
|
||||
let mut dec = filmcraft_ac3::Decoder::new();
|
||||
for fr in frames(&data).into_iter().take(8) {
|
||||
for k in 0..300 {
|
||||
let mut m = fr.to_vec();
|
||||
match k % 3 {
|
||||
0 => {
|
||||
for _ in 0..1 + rnd() % 8 {
|
||||
let bit = rnd() as usize % (m.len() * 8);
|
||||
m[bit / 8] ^= 0x80 >> (bit % 8);
|
||||
}
|
||||
}
|
||||
1 => m.truncate(rnd() as usize % m.len()),
|
||||
_ => {
|
||||
// keep the sync word, scramble frmsiz / fscod / numblkscod / acmod
|
||||
m[2] ^= rnd() as u8;
|
||||
m[3] ^= rnd() as u8;
|
||||
m[4] ^= rnd() as u8;
|
||||
}
|
||||
}
|
||||
let _ = dec.decode(&m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn generate_fixtures() {
|
||||
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("ac3 fixtures") else { return };
|
||||
for s in SPECS {
|
||||
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec));
|
||||
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name));
|
||||
filmcraft_testkit::fixtures::generate_and_report(&format!("ac3/{}", s.name), &[out], || make(&ff, s));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
[package]
|
||||
name = "filmcraft-apv"
|
||||
description = "Clean-room Advanced Professional Video (APV, IETF RFC 9924) decoder and encoder"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["threads"]
|
||||
threads = ["dep:rayon"]
|
||||
|
||||
[dependencies]
|
||||
filmcraft-bitstream.workspace = true
|
||||
rayon = { workspace = true, optional = true }
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
# filmcraft-apv
|
||||
|
||||
Clean-room Advanced Professional Video (APV) decoder and encoder in pure Rust, implemented strictly
|
||||
from the public **IETF RFC 9924** specification (*Advanced Professional Video*, February 2026,
|
||||
ISSN 2070-1721).
|
||||
|
||||
Layer L0: depends only on `filmcraft-bitstream`, `thiserror`, and (optional, feature `threads`,
|
||||
default on) `rayon`. Builds for `wasm32-unknown-unknown`; `unsafe_code = "forbid"`.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
use filmcraft_apv::*;
|
||||
|
||||
// Decode one APV Access Unit (or raw bitstream AU prefixed with a 32-bit au_size).
|
||||
let frame: Frame = decode_frame(&au)?;
|
||||
let f8 = decode_frame_with(&au, &DecodeOptions { bit_depth: Some(8), threads: true })?;
|
||||
decode_frame_into(&au, &DecodeOptions::default(), &mut frame_buf)?;
|
||||
let hdr: FrameHeader = probe(&au)?;
|
||||
|
||||
// Split a multi-AU raw .apv bitstream (RFC 9924 Appendix A).
|
||||
let aus: Vec<&[u8]> = split_raw_bitstream(&raw_apv_bytes)?;
|
||||
|
||||
// Encode progressive intra frames.
|
||||
let mut enc = Encoder::new(Profile::P422_10, 1920, 1080)?;
|
||||
let au: Vec<u8> = enc.encode(&frame)?; // Access Unit starting with 'aPv1'
|
||||
let raw_au: Vec<u8> = enc.encode_raw_au(&frame)?; // Prefixed with 32-bit au_size (ISOBMFF apv1 / .apv)
|
||||
let apvc: Vec<u8> = enc.decoder_config_record(); // APVDecoderConfigurationRecord payload
|
||||
```
|
||||
|
||||
## Specification Coverage (IETF RFC 9924)
|
||||
|
||||
### Decoder
|
||||
- **Access Units & PBUs (§5.3.1–5.3.3, Appendix A)**: Accepts both unprefixed Access Units
|
||||
(`'aPv1'` signature) and length-prefixed `raw_bitstream_access_unit()` packets. Parses primary
|
||||
(`pbu_type == 1`), non-primary (`2`), preview (`25`), auxiliary alpha (`27`), AU info (`65`),
|
||||
metadata (`66`), and filler (`67`) PBUs. PBUs or frame headers with non-zero `reserved_zero_*`
|
||||
fields are ignored per §5.3.3 and §5.3.5.
|
||||
- **Profiles & Sampling (§9.3)**: All 7 standard profiles (`422-10` idc 33, `422-12` idc 44,
|
||||
`444-10` idc 55, `444-12` idc 66, `4444-10` idc 77, `4444-12` idc 88, `400-10` idc 99) at
|
||||
coded bit depths 10–16, with optional output depth rescaling (`8..=16`).
|
||||
- **Quantization Matrices & Tiles (§5.3.7–5.3.8, §5.3.12–5.3.14)**: Default flat (`16`) and
|
||||
custom per-component 8×8 quantization matrices (`use_q_matrix`), optional `tile_size_in_fh`
|
||||
verification, and independent per-tile, per-component byte-aligned entropy partitions. Tiles
|
||||
decode in parallel via `rayon` when `threads` is enabled.
|
||||
- **Entropy Decoding (§5.3.15–5.3.16, §7.1)**: Adaptive Golomb-Rice / Exp-Golomb VLC decoding for
|
||||
`abs_dc_coeff_diff`, `coeff_zero_run`, and `abs_ac_coeff_minus1` with state tracking (`PrevDC`,
|
||||
`PrevDcDiff`, `Prev1stAcLevel`, `PrevLevel`, `PrevRun`) across the 8×8 blocks of each macroblock.
|
||||
- **Scaling & Inverse Transform (§6.3)**: Exact integer dequantization (`levelScale = [40, 45, 51,
|
||||
57, 64, 71]`, `bdShift = BitDepth - 2`) and 2-D separable 8×8 integer inverse transform using the
|
||||
RFC 9924 Figure 25 `transMatrix`. Note: in RFC 9924 §6.3.2.2, `transMatrix[m][n]` has row `m` as
|
||||
the frequency index (row 0 is `[64; 8]`) and column `n` as the spatial sample index, so the 1-D
|
||||
inverse transform evaluates `sum_j transMatrix[j][i] * x[j]`.
|
||||
- **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume)
|
||||
and `metadata_cll` (`payloadType == 6`, Content Light-Level Information).
|
||||
- **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient
|
||||
ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/fuzz.rs`
|
||||
across thousands of mutated and random inputs.
|
||||
|
||||
### Encoder
|
||||
- Progressive intra encoding for all 7 profiles (`422-10`, `422-12`, `444-10`, `444-12`,
|
||||
`4444-10`, `4444-12`, `400-10`).
|
||||
- Supports fixed `qp` or binary-search frame-budget rate control over forward-transformed tiles.
|
||||
- Emits conforming tile grids (`tile_width_in_mbs >= 16`, `tile_height_in_mbs >= 8`,
|
||||
`TileCols, TileRows <= 20`), optional custom quantization matrices, optional `MDCV`/`CLL`
|
||||
metadata PBUs, and `APVDecoderConfigurationRecord` (`apvC`) metadata.
|
||||
|
||||
## Accuracy (vs FFmpeg as an External Oracle)
|
||||
|
||||
`tests/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
|
||||
tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting
|
||||
`.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `filmcraft_apv::decode_frame`.
|
||||
Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact
|
||||
(`max_diff = 0`) with FFmpeg's APV decoder across every profile and test case.**
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
//! RFC 9924 §6.3 scaling and 8x8 integer inverse transform, plus the matching forward transform.
|
||||
|
||||
use crate::tables::LEVEL_SCALE;
|
||||
|
||||
/// 1D 8-point inverse transform using the RFC 9924 §6.3.2.3 even/odd butterfly decomposition,
|
||||
/// shifting each output by `(sum + round) >> shift`.
|
||||
#[inline(always)]
|
||||
fn idct_1d(x0: i32, x1: i32, x2: i32, x3: i32, x4: i32, x5: i32, x6: i32, x7: i32, round: i32, shift: u32) -> [i32; 8] {
|
||||
if (x1 | x2 | x3 | x4 | x5 | x6 | x7) == 0 {
|
||||
let v = (x0 * 64 + round) >> shift;
|
||||
return [v; 8];
|
||||
}
|
||||
let e0 = 64 * (x0 + x4);
|
||||
let e1 = 64 * (x0 - x4);
|
||||
let e2 = 35 * x2 - 84 * x6;
|
||||
let e3 = 84 * x2 + 35 * x6;
|
||||
|
||||
let even0 = e0 + e3;
|
||||
let even1 = e1 + e2;
|
||||
let even2 = e1 - e2;
|
||||
let even3 = e0 - e3;
|
||||
|
||||
let odd0 = 89 * x1 + 75 * x3 + 50 * x5 + 18 * x7;
|
||||
let odd1 = 75 * x1 - 18 * x3 - 89 * x5 - 50 * x7;
|
||||
let odd2 = 50 * x1 - 89 * x3 + 18 * x5 + 75 * x7;
|
||||
let odd3 = 18 * x1 - 50 * x3 + 75 * x5 - 89 * x7;
|
||||
|
||||
[
|
||||
(even0 + odd0 + round) >> shift,
|
||||
(even1 + odd1 + round) >> shift,
|
||||
(even2 + odd2 + round) >> shift,
|
||||
(even3 + odd3 + round) >> shift,
|
||||
(even3 - odd3 + round) >> shift,
|
||||
(even2 - odd2 + round) >> shift,
|
||||
(even1 - odd1 + round) >> shift,
|
||||
(even0 - odd0 + round) >> shift,
|
||||
]
|
||||
}
|
||||
|
||||
/// Fast path when an 8x8 block has only a DC coefficient (`coeff[1..64]` are all zero).
|
||||
#[inline]
|
||||
pub fn scale_and_idct8x8_dc_only(dc_coeff: i16, q0: u8, qp: u8, bit_depth: u8, out: &mut [u16; 64]) {
|
||||
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
|
||||
let scale_round = if bd_shift_scale > 0 { 1i64 << (bd_shift_scale - 1) } else { 0 };
|
||||
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
|
||||
let qp_shift = (qp / 6) as u32;
|
||||
|
||||
let scaled = ((dc_coeff as i64 * q0 as i64 * level_scale) << qp_shift) + scale_round;
|
||||
let d0 = (scaled >> bd_shift_scale).clamp(-32768, 32767) as i32;
|
||||
let g0 = (d0 * 64 + 64) >> 7;
|
||||
|
||||
let bd_shift = 20u32.saturating_sub(bit_depth as u32);
|
||||
let round = 1i32 << (bd_shift - 1);
|
||||
let mid = 1i32 << (bit_depth - 1);
|
||||
let max_val = (1i32 << bit_depth) - 1;
|
||||
let sample = (((g0 * 64 + round) >> bd_shift) + mid).clamp(0, max_val) as u16;
|
||||
out.fill(sample);
|
||||
}
|
||||
|
||||
/// Scale and inverse-transform one 8x8 block of transform coefficients (indexed by `y * 8 + x`)
|
||||
/// into reconstructed samples at `bit_depth` (`10..=16`), per RFC 9924 §6.3.
|
||||
#[inline]
|
||||
pub fn scale_and_idct8x8(coeff: &[i16; 64], q_matrix: &[u8; 64], qp: u8, bit_depth: u8, out: &mut [u16; 64]) {
|
||||
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
|
||||
let scale_round = if bd_shift_scale > 0 { 1i64 << (bd_shift_scale - 1) } else { 0 };
|
||||
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
|
||||
let qp_shift = (qp / 6) as u32;
|
||||
|
||||
let mut d = [0i32; 64];
|
||||
for idx in 0..64 {
|
||||
let c = coeff[idx] as i64;
|
||||
if c != 0 {
|
||||
let qm = q_matrix[idx] as i64;
|
||||
let scaled = ((c * qm * level_scale) << qp_shift) + scale_round;
|
||||
d[idx] = (scaled >> bd_shift_scale).clamp(-32768, 32767) as i32;
|
||||
}
|
||||
}
|
||||
|
||||
// 1D vertical transform on each column x = 0..8, followed by (e + 64) >> 7
|
||||
let mut g = [0i32; 64];
|
||||
for x in 0..8 {
|
||||
let col = idct_1d(d[x], d[8 + x], d[16 + x], d[24 + x], d[32 + x], d[40 + x], d[48 + x], d[56 + x], 64, 7);
|
||||
for i in 0..8 {
|
||||
g[i * 8 + x] = col[i];
|
||||
}
|
||||
}
|
||||
|
||||
// 1D horizontal transform on each row y = 0..8, followed by final bit-depth shift and offset
|
||||
let bd_shift = 20u32.saturating_sub(bit_depth as u32);
|
||||
let round = 1i32 << (bd_shift - 1);
|
||||
let mid = 1i32 << (bit_depth - 1);
|
||||
let max_val = (1i32 << bit_depth) - 1;
|
||||
|
||||
for y in 0..8 {
|
||||
let r = &g[y * 8..y * 8 + 8];
|
||||
let row = idct_1d(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], round, bd_shift);
|
||||
for i in 0..8 {
|
||||
out[y * 8 + i] = (row[i] + mid).clamp(0, max_val) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1D 8-point forward butterfly transform matching `TRANS_MATRIX`.
|
||||
#[inline(always)]
|
||||
fn fdct_1d(x0: i32, x1: i32, x2: i32, x3: i32, x4: i32, x5: i32, x6: i32, x7: i32, round: i32, shift: u32) -> [i32; 8] {
|
||||
let s0 = x0 + x7;
|
||||
let d0 = x0 - x7;
|
||||
let s1 = x1 + x6;
|
||||
let d1 = x1 - x6;
|
||||
let s2 = x2 + x5;
|
||||
let d2 = x2 - x5;
|
||||
let s3 = x3 + x4;
|
||||
let d3 = x3 - x4;
|
||||
|
||||
let es0 = s0 + s3;
|
||||
let ed0 = s0 - s3;
|
||||
let es1 = s1 + s2;
|
||||
let ed1 = s1 - s2;
|
||||
|
||||
[
|
||||
(64 * (es0 + es1) + round) >> shift,
|
||||
(89 * d0 + 75 * d1 + 50 * d2 + 18 * d3 + round) >> shift,
|
||||
(84 * ed0 + 35 * ed1 + round) >> shift,
|
||||
(75 * d0 - 18 * d1 - 89 * d2 - 50 * d3 + round) >> shift,
|
||||
(64 * (es0 - es1) + round) >> shift,
|
||||
(50 * d0 - 89 * d1 + 18 * d2 + 75 * d3 + round) >> shift,
|
||||
(35 * ed0 - 84 * ed1 + round) >> shift,
|
||||
(18 * d0 - 50 * d1 + 75 * d2 - 89 * d3 + round) >> shift,
|
||||
]
|
||||
}
|
||||
|
||||
/// Forward 8x8 transform of level-shifted samples (`sample - (1 << (bit_depth - 1))`) into the
|
||||
/// scaled coefficient domain `d[y * 8 + x]` matching [`scale_and_idct8x8`].
|
||||
#[inline]
|
||||
pub fn fdct8x8(samples: &[u16; 64], bit_depth: u8) -> [i32; 64] {
|
||||
let mid = 1i32 << (bit_depth - 1);
|
||||
let shift1 = (bit_depth as u32).saturating_sub(5);
|
||||
let add1 = if shift1 > 0 { 1i32 << (shift1 - 1) } else { 0 };
|
||||
|
||||
// Horizontal forward pass
|
||||
let mut t = [0i32; 64];
|
||||
for y in 0..8 {
|
||||
let s = &samples[y * 8..y * 8 + 8];
|
||||
let row = fdct_1d(
|
||||
s[0] as i32 - mid,
|
||||
s[1] as i32 - mid,
|
||||
s[2] as i32 - mid,
|
||||
s[3] as i32 - mid,
|
||||
s[4] as i32 - mid,
|
||||
s[5] as i32 - mid,
|
||||
s[6] as i32 - mid,
|
||||
s[7] as i32 - mid,
|
||||
add1,
|
||||
shift1,
|
||||
);
|
||||
t[y * 8..y * 8 + 8].copy_from_slice(&row);
|
||||
}
|
||||
|
||||
// Vertical forward pass
|
||||
let mut f = [0i32; 64];
|
||||
for u in 0..8 {
|
||||
let col = fdct_1d(t[u], t[8 + u], t[16 + u], t[24 + u], t[32 + u], t[40 + u], t[48 + u], t[56 + u], 128, 8);
|
||||
for v in 0..8 {
|
||||
f[v * 8 + u] = col[v];
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Quantize forward-transformed coefficients `f` (in the `d` domain) at `qp` with `q_matrix`.
|
||||
#[inline]
|
||||
pub fn quantize8x8(f: &[i32; 64], q_matrix: &[u8; 64], qp: u8, bit_depth: u8) -> [i16; 64] {
|
||||
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
|
||||
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
|
||||
let qp_shift = (qp / 6) as u32;
|
||||
|
||||
let mut out = [0i16; 64];
|
||||
for idx in 0..64 {
|
||||
let val = f[idx] as i64;
|
||||
if val == 0 {
|
||||
continue;
|
||||
}
|
||||
let sign = if val < 0 { -1i64 } else { 1i64 };
|
||||
let abs_val = val.unsigned_abs() as i64;
|
||||
let qm = q_matrix[idx].max(1) as i64;
|
||||
let den = (qm * level_scale) << qp_shift;
|
||||
let num = abs_val << bd_shift_scale;
|
||||
// Deadzone/rounding offset: 1/2 for DC, 3/8 for AC
|
||||
let offset = if idx == 0 { den / 2 } else { (den * 3) / 8 };
|
||||
let level = ((num + offset) / den).min(32767);
|
||||
out[idx] = (level * sign) as i16;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fdct_idct_roundtrip_10bit() {
|
||||
let mut src = [0u16; 64];
|
||||
for (i, s) in src.iter_mut().enumerate() {
|
||||
*s = (64 + (i * 13) % 850) as u16;
|
||||
}
|
||||
let qm = [16u8; 64];
|
||||
// At qp = 0 (very fine quantization), roundtrip is within 1 LSB
|
||||
let f = fdct8x8(&src, 10);
|
||||
let q = quantize8x8(&f, &qm, 0, 10);
|
||||
let mut rec = [0u16; 64];
|
||||
scale_and_idct8x8(&q, &qm, 0, 10, &mut rec);
|
||||
for i in 0..64 {
|
||||
let err = (src[i] as i32 - rec[i] as i32).abs();
|
||||
assert!(err <= 1, "idx {i}: src={} rec={} err={err}", src[i], rec[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,484 +0,0 @@
|
|||
//! RFC 9924 APV frame and tile decoder.
|
||||
|
||||
use filmcraft_bitstream::BitReader;
|
||||
|
||||
use crate::dct::{scale_and_idct8x8, scale_and_idct8x8_dc_only};
|
||||
use crate::header::{
|
||||
FrameHeader, PBU_ALPHA_FRAME, PBU_METADATA, PBU_NON_PRIMARY_FRAME, PBU_PREVIEW_FRAME, PBU_PRIMARY_FRAME, parse_au_pbus, parse_frame_header,
|
||||
parse_metadata_pbu, read_byte_alignment,
|
||||
};
|
||||
use crate::tables::ZIGZAG_8X8;
|
||||
use crate::{ChromaFormat, ContentLightLevel, Error, Frame, MasteringDisplay, Result};
|
||||
|
||||
/// Options for [`decode_frame_with`] and [`decode_frame_into`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecodeOptions {
|
||||
/// Output sample bit depth (`8..=16`). `None` keeps the stream's coded bit depth (`10..=16`).
|
||||
pub bit_depth: Option<u8>,
|
||||
/// Decode tiles in parallel when the `threads` feature is enabled.
|
||||
pub threads: bool,
|
||||
}
|
||||
|
||||
impl Default for DecodeOptions {
|
||||
fn default() -> Self {
|
||||
Self { bit_depth: None, threads: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse RFC 9924 §7.1.4 variable-length codeword (`h(v)`).
|
||||
#[inline]
|
||||
pub(crate) fn read_vlc(r: &mut BitReader, k_param: u32) -> Result<u32> {
|
||||
let mut symbol_value: u64 = 0;
|
||||
let mut k = k_param;
|
||||
let parse_exp_golomb = if r.read_bit().map_err(|_| Error::Truncated)? {
|
||||
false
|
||||
} else if !r.read_bit().map_err(|_| Error::Truncated)? {
|
||||
symbol_value += 1u64 << k;
|
||||
false
|
||||
} else {
|
||||
symbol_value += 2u64 << k;
|
||||
true
|
||||
};
|
||||
if parse_exp_golomb {
|
||||
loop {
|
||||
if r.read_bit().map_err(|_| Error::Truncated)? {
|
||||
break;
|
||||
}
|
||||
symbol_value += 1u64 << k;
|
||||
k += 1;
|
||||
if k > 28 {
|
||||
return Err(Error::Invalid("VLC codeword exceeds maximum length"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if k > 0 {
|
||||
symbol_value += r.read_bits(k).map_err(|_| Error::Truncated)? as u64;
|
||||
}
|
||||
u32::try_from(symbol_value).map_err(|_| Error::Invalid("VLC symbol overflow"))
|
||||
}
|
||||
|
||||
/// Probe the primary frame header of an Access Unit without decoding its tiles.
|
||||
pub fn probe(data: &[u8]) -> Result<FrameHeader> {
|
||||
let pbus = parse_au_pbus(data)?;
|
||||
for pbu in &pbus {
|
||||
if matches!(pbu.pbu_type, PBU_PRIMARY_FRAME | PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME)
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
return Ok(hdr);
|
||||
}
|
||||
}
|
||||
Err(Error::Invalid("no decodable frame PBU in access unit"))
|
||||
}
|
||||
|
||||
/// Decode one APV Access Unit with default options.
|
||||
pub fn decode_frame(data: &[u8]) -> Result<Frame> {
|
||||
decode_frame_with(data, &DecodeOptions::default())
|
||||
}
|
||||
|
||||
/// Decode one APV Access Unit with custom [`DecodeOptions`].
|
||||
pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result<Frame> {
|
||||
let mut out = Frame::new(2, 2, ChromaFormat::Yuv422, 10, false);
|
||||
decode_frame_into(data, opts, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode one APV Access Unit into `out`, reusing its plane allocations when possible.
|
||||
pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<FrameHeader> {
|
||||
if let Some(d) = opts.bit_depth
|
||||
&& !(8..=16).contains(&d)
|
||||
{
|
||||
return Err(Error::Invalid("requested output bit_depth must be in 8..=16"));
|
||||
}
|
||||
|
||||
let pbus = parse_au_pbus(data)?;
|
||||
let mut primary: Option<(u16, FrameHeader, &[u8])> = None;
|
||||
let mut alpha_pbu: Option<(FrameHeader, &[u8])> = None;
|
||||
let mut mdcv: Option<MasteringDisplay> = None;
|
||||
let mut cll: Option<ContentLightLevel> = None;
|
||||
|
||||
for pbu in &pbus {
|
||||
match pbu.pbu_type {
|
||||
PBU_PRIMARY_FRAME => {
|
||||
if primary.is_none()
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
primary = Some((pbu.group_id, hdr, pbu.payload));
|
||||
}
|
||||
}
|
||||
PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME => {
|
||||
if primary.is_none()
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
primary = Some((pbu.group_id, hdr, pbu.payload));
|
||||
}
|
||||
}
|
||||
PBU_ALPHA_FRAME => {
|
||||
if alpha_pbu.is_none()
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
alpha_pbu = Some((hdr, pbu.payload));
|
||||
}
|
||||
}
|
||||
PBU_METADATA => {
|
||||
let _ = parse_metadata_pbu(pbu.payload, &mut mdcv, &mut cll);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let (_group_id, hdr, payload) = primary.ok_or(Error::Invalid("no primary frame PBU in access unit"))?;
|
||||
let out_depth = opts.bit_depth.unwrap_or(hdr.bit_depth);
|
||||
|
||||
decode_pbu_planes(&hdr, payload, out_depth, opts.threads, out)?;
|
||||
out.mastering_display = mdcv;
|
||||
out.content_light = cll;
|
||||
|
||||
// If the primary frame has no 4th component and an auxiliary alpha frame PBU (type 27) is present
|
||||
// with matching dimensions, decode its luma plane as the alpha plane.
|
||||
if out.alpha.is_none()
|
||||
&& let Some((ahdr, apayload)) = alpha_pbu
|
||||
&& ahdr.width == hdr.width
|
||||
&& ahdr.height == hdr.height
|
||||
{
|
||||
let mut aframe = Frame::new(ahdr.width, ahdr.height, ahdr.chroma, out_depth, false);
|
||||
if decode_pbu_planes(&ahdr, apayload, out_depth, opts.threads, &mut aframe).is_ok() {
|
||||
out.alpha = Some(aframe.y);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(hdr)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DecodedTile {
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
w_luma: u32,
|
||||
h_luma: u32,
|
||||
/// Decoded pixels for each component `c` in `0..num_comps`, packed with stride `w_luma / sub_w`.
|
||||
comps: [Vec<u16>; 4],
|
||||
}
|
||||
|
||||
fn decode_pbu_planes(hdr: &FrameHeader, payload: &[u8], out_depth: u8, threads: bool, out: &mut Frame) -> Result<()> {
|
||||
let num_tiles = hdr.num_tiles();
|
||||
let mut pos = hdr.header_bytes;
|
||||
let mut tile_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_tiles);
|
||||
|
||||
for tile_idx in 0..num_tiles {
|
||||
let sz_bytes = payload.get(pos..pos + 4).ok_or(Error::Truncated)?;
|
||||
let tile_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
|
||||
if tile_size == 0 {
|
||||
return Err(Error::Invalid("tile_size cannot be 0"));
|
||||
}
|
||||
if let Some(fh_sizes) = &hdr.tile_sizes_in_fh
|
||||
&& fh_sizes.get(tile_idx).copied() != Some(tile_size as u32)
|
||||
{
|
||||
return Err(Error::Invalid("tile_size does not match tile_size_in_fh"));
|
||||
}
|
||||
pos += 4;
|
||||
let tile_end = pos.checked_add(tile_size).ok_or(Error::Truncated)?;
|
||||
let tile_bytes = payload.get(pos..tile_end).ok_or(Error::Truncated)?;
|
||||
pos = tile_end;
|
||||
tile_slices.push((tile_idx, tile_bytes));
|
||||
}
|
||||
|
||||
// Verify any trailing filler bytes in the frame PBU are 0xFF (RFC 9924 §5.3.4 & §5.3.11)
|
||||
if payload.get(pos..).is_some_and(|rest| rest.iter().any(|&b| b != 0xFF)) {
|
||||
return Err(Error::Invalid("non-0xFF filler byte at end of frame PBU"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
let decoded_tiles: Result<Vec<DecodedTile>> = if threads && tile_slices.len() > 1 {
|
||||
use rayon::prelude::*;
|
||||
tile_slices.into_par_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
|
||||
} else {
|
||||
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, threads)).collect()
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let decoded_tiles: Result<Vec<DecodedTile>> = {
|
||||
let _ = threads;
|
||||
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
|
||||
};
|
||||
|
||||
let mut decoded_tiles = decoded_tiles?;
|
||||
|
||||
// Prepare output frame metadata
|
||||
let w = hdr.width as usize;
|
||||
let h = hdr.height as usize;
|
||||
let cw = hdr.chroma.chroma_width(hdr.width) as usize;
|
||||
let ch = hdr.chroma.chroma_height(hdr.height) as usize;
|
||||
let n_luma = w.checked_mul(h).ok_or(Error::Invalid("frame luma size overflow"))?;
|
||||
let n_chroma = cw.checked_mul(ch).ok_or(Error::Invalid("frame chroma size overflow"))?;
|
||||
|
||||
out.width = hdr.width;
|
||||
out.height = hdr.height;
|
||||
out.chroma = hdr.chroma;
|
||||
out.bit_depth = out_depth;
|
||||
out.color = hdr.color;
|
||||
out.profile_idc = hdr.profile_idc;
|
||||
out.level_idc = hdr.level_idc;
|
||||
out.band_idc = hdr.band_idc;
|
||||
|
||||
// Zero-copy fast path when a single tile covers the exact frame dimensions without macroblock padding.
|
||||
if decoded_tiles.len() == 1 && decoded_tiles[0].w_luma == hdr.width && decoded_tiles[0].h_luma == hdr.height {
|
||||
let [y, cb, cr, a] = std::mem::take(&mut decoded_tiles[0].comps);
|
||||
out.y = y;
|
||||
if hdr.chroma.num_comps() >= 3 {
|
||||
out.cb = cb;
|
||||
out.cr = cr;
|
||||
} else {
|
||||
out.cb.clear();
|
||||
out.cr.clear();
|
||||
}
|
||||
out.alpha = if hdr.chroma == ChromaFormat::Yuv4444 { Some(a) } else { None };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
out.y.resize(n_luma, 0);
|
||||
out.cb.resize(n_chroma, 0);
|
||||
out.cr.resize(n_chroma, 0);
|
||||
if hdr.chroma == ChromaFormat::Yuv4444 {
|
||||
let a = out.alpha.get_or_insert_with(Vec::new);
|
||||
a.resize(n_luma, 0);
|
||||
} else {
|
||||
out.alpha = None;
|
||||
}
|
||||
|
||||
// Copy each tile's reconstructed samples into the cropped output planes (RFC 9924 §6)
|
||||
let sub_w = hdr.chroma.sub_width_c() as usize;
|
||||
let sub_h = hdr.chroma.sub_height_c() as usize;
|
||||
|
||||
for tile in &decoded_tiles {
|
||||
copy_tile_comp(&tile.comps[0], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, tile.y0 as usize, &mut out.y, w, h);
|
||||
if hdr.chroma.num_comps() >= 3 {
|
||||
let tw_c = (tile.w_luma as usize) / sub_w;
|
||||
let th_c = (tile.h_luma as usize) / sub_h;
|
||||
let tx_c = (tile.x0 as usize) / sub_w;
|
||||
let ty_c = (tile.y0 as usize) / sub_h;
|
||||
copy_tile_comp(&tile.comps[1], tw_c, th_c, tx_c, ty_c, &mut out.cb, cw, ch);
|
||||
copy_tile_comp(&tile.comps[2], tw_c, th_c, tx_c, ty_c, &mut out.cr, cw, ch);
|
||||
}
|
||||
if hdr.chroma == ChromaFormat::Yuv4444
|
||||
&& let Some(alpha) = out.alpha.as_mut()
|
||||
{
|
||||
copy_tile_comp(&tile.comps[3], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, tile.y0 as usize, alpha, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_tile_comp(src: &[u16], tw: usize, th: usize, tx: usize, ty: usize, dst: &mut [u16], fw: usize, fh: usize) {
|
||||
if tx >= fw || ty >= fh {
|
||||
return;
|
||||
}
|
||||
let copy_w = tw.min(fw - tx);
|
||||
let copy_h = th.min(fh - ty);
|
||||
for r in 0..copy_h {
|
||||
let s_off = r * tw;
|
||||
let d_off = (ty + r) * fw + tx;
|
||||
dst[d_off..d_off + copy_w].copy_from_slice(&src[s_off..s_off + copy_w]);
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_single_tile(hdr: &FrameHeader, tile_idx: usize, tile_bytes: &[u8], out_depth: u8, par_comps: bool) -> Result<DecodedTile> {
|
||||
let num_comps = hdr.chroma.num_comps();
|
||||
let min_hdr_size = 5 + 5 * num_comps;
|
||||
if tile_bytes.len() < min_hdr_size {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
|
||||
// tile_header(tileIdx) (RFC 9924 §5.3.13)
|
||||
let tile_header_size = u16::from_be_bytes([tile_bytes[0], tile_bytes[1]]) as usize;
|
||||
let tile_index = u16::from_be_bytes([tile_bytes[2], tile_bytes[3]]) as usize;
|
||||
if tile_index != tile_idx {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
if tile_header_size < min_hdr_size || tile_header_size > tile_bytes.len() {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
|
||||
let mut tile_data_size = [0usize; 4];
|
||||
for c in 0..num_comps {
|
||||
let off = 4 + c * 4;
|
||||
let sz = u32::from_be_bytes([tile_bytes[off], tile_bytes[off + 1], tile_bytes[off + 2], tile_bytes[off + 3]]) as usize;
|
||||
if sz == 0 {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
tile_data_size[c] = sz;
|
||||
}
|
||||
|
||||
let max_tile_qp = (51 + hdr.qp_bd_offset()) as u8;
|
||||
let mut tile_qp = [0u8; 4];
|
||||
for c in 0..num_comps {
|
||||
let qp = tile_bytes[4 + num_comps * 4 + c];
|
||||
if qp > max_tile_qp {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
tile_qp[c] = qp;
|
||||
}
|
||||
|
||||
let reserved_zero_8bits = tile_bytes[4 + num_comps * 5];
|
||||
if reserved_zero_8bits != 0 {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
|
||||
let tx = tile_idx % (hdr.tile_cols as usize);
|
||||
let ty = tile_idx / (hdr.tile_cols as usize);
|
||||
let x0 = hdr.col_starts[tx];
|
||||
let x1 = hdr.col_starts[tx + 1];
|
||||
let y0 = hdr.row_starts[ty];
|
||||
let y1 = hdr.row_starts[ty + 1];
|
||||
let w_luma = x1.saturating_sub(x0);
|
||||
let h_luma = y1.saturating_sub(y0);
|
||||
|
||||
let mut comp_off = tile_header_size;
|
||||
let mut comp_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_comps);
|
||||
for c in 0..num_comps {
|
||||
let comp_end = comp_off.checked_add(tile_data_size[c]).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
let comp_bytes = tile_bytes.get(comp_off..comp_end).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
comp_off = comp_end;
|
||||
comp_slices.push((c, comp_bytes));
|
||||
}
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
let decoded_comps: Result<Vec<Vec<u16>>> = if par_comps && comp_slices.len() > 1 {
|
||||
use rayon::prelude::*;
|
||||
comp_slices.into_par_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
|
||||
} else {
|
||||
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let decoded_comps: Result<Vec<Vec<u16>>> = {
|
||||
let _ = par_comps;
|
||||
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
|
||||
};
|
||||
|
||||
let mut comps: [Vec<u16>; 4] = Default::default();
|
||||
for (c, buf) in decoded_comps?.into_iter().enumerate() {
|
||||
comps[c] = buf;
|
||||
}
|
||||
|
||||
Ok(DecodedTile { x0, y0, w_luma, h_luma, comps })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[u8], qp: u8, w_luma: u32, h_luma: u32, out_depth: u8) -> Result<Vec<u16>> {
|
||||
let num_mb_cols = w_luma / 16;
|
||||
let num_mb_rows = h_luma / 16;
|
||||
let sub_w = if c == 0 { 1 } else { hdr.chroma.sub_width_c() };
|
||||
let sub_h = if c == 0 { 1 } else { hdr.chroma.sub_height_c() };
|
||||
let tw_c = (w_luma / sub_w) as usize;
|
||||
let th_c = (h_luma / sub_h) as usize;
|
||||
let blk_w = (16 / sub_w) as usize;
|
||||
let blk_h = (16 / sub_h) as usize;
|
||||
let blocks_x = blk_w / 8;
|
||||
let blocks_y = blk_h / 8;
|
||||
|
||||
let mut comp_buf = vec![0u16; tw_c * th_c];
|
||||
let mut r = BitReader::new(comp_bytes);
|
||||
|
||||
let mut prev_dc: i32 = 0;
|
||||
let mut prev_dc_diff: u32 = 20;
|
||||
let mut prev_1st_ac_level: u32 = 0;
|
||||
|
||||
let q_mat = &hdr.q_matrix[c];
|
||||
let coded_depth = hdr.bit_depth;
|
||||
|
||||
for mb_y in 0..(num_mb_rows as usize) {
|
||||
for mb_x in 0..(num_mb_cols as usize) {
|
||||
for by in 0..blocks_y {
|
||||
for bx in 0..blocks_x {
|
||||
// abs_dc_coeff_diff (RFC 9924 §5.3.15 & §7.1.1)
|
||||
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||
let abs_dc_diff = read_vlc(&mut r, k_dc).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
let sign_dc = if abs_dc_diff > 0 { r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))? } else { false };
|
||||
let diff = if sign_dc { -(abs_dc_diff as i64) } else { abs_dc_diff as i64 };
|
||||
let dc = prev_dc as i64 + diff;
|
||||
if !(-32768..=32767).contains(&dc) {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
prev_dc = dc as i32;
|
||||
prev_dc_diff = abs_dc_diff;
|
||||
|
||||
let mut coeff = [0i16; 64];
|
||||
coeff[0] = dc as i16;
|
||||
|
||||
// ac_coeff_coding (RFC 9924 §5.3.16 & §7.1.2–7.1.3)
|
||||
let mut scan_pos = 1usize;
|
||||
let mut first_ac = true;
|
||||
let mut prev_level = prev_1st_ac_level;
|
||||
let mut prev_run = 0u32;
|
||||
|
||||
while scan_pos < 64 {
|
||||
let k_run = (prev_run >> 2).min(2);
|
||||
let run = read_vlc(&mut r, k_run).map_err(|_| Error::CorruptTile(tile_idx))? as usize;
|
||||
scan_pos = scan_pos.checked_add(run).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
if scan_pos > 64 {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
prev_run = run as u32;
|
||||
if scan_pos < 64 {
|
||||
let k_lvl = (prev_level >> 2).min(4);
|
||||
let abs_ac_minus1 = read_vlc(&mut r, k_lvl).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
let sign_ac = r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
let abs_level = (abs_ac_minus1 as u64) + 1;
|
||||
let level_i64 = if sign_ac { -(abs_level as i64) } else { abs_level as i64 };
|
||||
if !(-32768..=32767).contains(&level_i64) {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
coeff[ZIGZAG_8X8[scan_pos] as usize] = level_i64 as i16;
|
||||
scan_pos += 1;
|
||||
prev_level = abs_level as u32;
|
||||
if first_ac {
|
||||
first_ac = false;
|
||||
prev_1st_ac_level = prev_level;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rec = [0u16; 64];
|
||||
if first_ac {
|
||||
scale_and_idct8x8_dc_only(coeff[0], q_mat[0], qp, coded_depth, &mut rec);
|
||||
} else {
|
||||
scale_and_idct8x8(&coeff, q_mat, qp, coded_depth, &mut rec);
|
||||
}
|
||||
if out_depth != coded_depth {
|
||||
rescale_block(&mut rec, coded_depth, out_depth);
|
||||
}
|
||||
|
||||
let px0 = mb_x * blk_w + bx * 8;
|
||||
let py0 = mb_y * blk_h + by * 8;
|
||||
for ry in 0..8 {
|
||||
let dst_row = (py0 + ry) * tw_c + px0;
|
||||
comp_buf[dst_row..dst_row + 8].copy_from_slice(&rec[ry * 8..ry * 8 + 8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
read_byte_alignment(&mut r).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
Ok(comp_buf)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rescale_block(block: &mut [u16; 64], from_depth: u8, to_depth: u8) {
|
||||
if to_depth > from_depth {
|
||||
let shift = (to_depth - from_depth) as u32;
|
||||
let fill_shift = from_depth as u32;
|
||||
for s in block.iter_mut() {
|
||||
let v = *s as u32;
|
||||
*s = ((v << shift) | (v >> (fill_shift - shift))) as u16;
|
||||
}
|
||||
} else if to_depth < from_depth {
|
||||
let shift = (from_depth - to_depth) as u32;
|
||||
let round = 1u32 << (shift - 1);
|
||||
let max_v = (1u32 << to_depth) - 1;
|
||||
for s in block.iter_mut() {
|
||||
*s = (((*s as u32) + round) >> shift).min(max_v) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,598 +0,0 @@
|
|||
//! RFC 9924 APV intra frame encoder.
|
||||
|
||||
use filmcraft_bitstream::BitWriter;
|
||||
|
||||
use crate::dct::{fdct8x8, quantize8x8};
|
||||
use crate::header::{FrameHeader, MAX_DIMENSION, PBU_PRIMARY_FRAME, write_frame_header, write_metadata_pbu};
|
||||
use crate::tables::{DEFAULT_Q_MATRIX_VAL, ZIGZAG_8X8, select_level_and_band};
|
||||
use crate::{ChromaFormat, ColorInfo, ContentLightLevel, Error, Frame, MasteringDisplay, Profile, Result};
|
||||
|
||||
/// Generate RFC 9924 §7.2.4 variable-length codeword (`h(v)`).
|
||||
#[inline]
|
||||
pub(crate) fn write_vlc(w: &mut BitWriter, symbol_val: u32, k_param: u32) {
|
||||
const PREFIX_VLC: [[u32; 2]; 3] = [[1, 0], [0, 0], [0, 1]];
|
||||
let mut symbol_value = symbol_val;
|
||||
let val_prefix = ((symbol_val >> k_param) as usize).min(2);
|
||||
let mut bit_count = 0usize;
|
||||
let mut k = k_param;
|
||||
|
||||
while symbol_value >= (1u32 << k) {
|
||||
symbol_value -= 1u32 << k;
|
||||
if bit_count < 2 {
|
||||
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
|
||||
} else {
|
||||
w.write_bits(0, 1);
|
||||
}
|
||||
if bit_count >= 2 {
|
||||
k += 1;
|
||||
}
|
||||
bit_count += 1;
|
||||
}
|
||||
if bit_count < 2 {
|
||||
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
|
||||
} else {
|
||||
w.write_bits(1, 1);
|
||||
}
|
||||
if k > 0 {
|
||||
w.write_bits(symbol_value, k);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact bit length of a VLC codeword generated by [`write_vlc`].
|
||||
#[inline]
|
||||
pub(crate) fn vlc_bit_len(symbol_val: u32, k_param: u32) -> u32 {
|
||||
if symbol_val < (1u32 << k_param) {
|
||||
1 + k_param
|
||||
} else if symbol_val < (2u32 << k_param) {
|
||||
2 + k_param
|
||||
} else {
|
||||
let rem = (symbol_val >> k_param) - 1;
|
||||
let zeros = 31 - rem.leading_zeros();
|
||||
3 + k_param + 2 * zeros
|
||||
}
|
||||
}
|
||||
|
||||
/// APV encoder configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EncoderConfig {
|
||||
pub profile: Profile,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub chroma: ChromaFormat,
|
||||
pub bit_depth: u8,
|
||||
pub color: ColorInfo,
|
||||
pub color_description_present: bool,
|
||||
/// Fixed tile quantization parameter (`0..=51 + QpBdOffset`). When `None`, rate control
|
||||
/// chooses a uniform QP per frame to fit [`Self::target_frame_bytes`].
|
||||
pub qp: Option<u8>,
|
||||
/// Target encoded frame size in bytes when `qp` is `None`.
|
||||
pub target_frame_bytes: u32,
|
||||
/// Tile width in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 16`).
|
||||
pub tile_width_in_mbs: u32,
|
||||
/// Tile height in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 8`).
|
||||
pub tile_height_in_mbs: u32,
|
||||
pub level_idc: u8,
|
||||
pub band_idc: u8,
|
||||
/// Optional custom per-component 8x8 quantization matrices (`y * 8 + x`).
|
||||
pub q_matrix: Option<[[u8; 64]; 4]>,
|
||||
/// Write `tile_size_in_fh` array in `frame_header()` (`tile_size_present_in_fh_flag = 1`).
|
||||
pub write_tile_sizes_in_fh: bool,
|
||||
pub mastering_display: Option<MasteringDisplay>,
|
||||
pub content_light: Option<ContentLightLevel>,
|
||||
}
|
||||
|
||||
impl EncoderConfig {
|
||||
pub fn new(profile: Profile, width: u32, height: u32) -> Self {
|
||||
let chroma = profile.default_chroma();
|
||||
let bit_depth = profile.default_bit_depth();
|
||||
let w_mbs = width.div_ceil(16).max(1);
|
||||
let h_mbs = height.div_ceil(16).max(1);
|
||||
// Choose conforming tile dimensions (>= 16x8 MBs, <= 20x20 tiles) with ~4-8 tiles on HD/4K.
|
||||
let tile_width_in_mbs = w_mbs.div_ceil(4).max(16).max(w_mbs.div_ceil(20));
|
||||
let tile_height_in_mbs = h_mbs.div_ceil(4).max(8).max(h_mbs.div_ceil(20));
|
||||
let target_frame_bytes = profile.nominal_frame_bytes(width, height);
|
||||
let kbps = ((target_frame_bytes as u64) * 8 * 30 / 1000).max(1000) as u32;
|
||||
let (level_idc, band_idc) = select_level_and_band((width as u64) * (height as u64) * 30, kbps);
|
||||
Self {
|
||||
profile,
|
||||
width,
|
||||
height,
|
||||
chroma,
|
||||
bit_depth,
|
||||
color: ColorInfo::BT709,
|
||||
color_description_present: true,
|
||||
qp: None,
|
||||
target_frame_bytes,
|
||||
tile_width_in_mbs,
|
||||
tile_height_in_mbs,
|
||||
level_idc,
|
||||
band_idc,
|
||||
q_matrix: None,
|
||||
write_tile_sizes_in_fh: false,
|
||||
mastering_display: None,
|
||||
content_light: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// APV intra encoder.
|
||||
pub struct Encoder {
|
||||
cfg: EncoderConfig,
|
||||
hdr: FrameHeader,
|
||||
}
|
||||
|
||||
struct PreparedTileComp {
|
||||
/// Forward-transformed 8x8 blocks in MB-then-subblock scan order.
|
||||
blocks: Vec<[i32; 64]>,
|
||||
}
|
||||
|
||||
struct PreparedTile {
|
||||
tile_idx: usize,
|
||||
comps: Vec<PreparedTileComp>,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn new(profile: Profile, width: u32, height: u32) -> Result<Self> {
|
||||
Self::with_config(EncoderConfig::new(profile, width, height))
|
||||
}
|
||||
|
||||
pub fn with_config(cfg: EncoderConfig) -> Result<Self> {
|
||||
if cfg.width == 0 || cfg.height == 0 || cfg.width > MAX_DIMENSION || cfg.height > MAX_DIMENSION {
|
||||
return Err(Error::Input(format!("unsupported dimensions {}x{}", cfg.width, cfg.height)));
|
||||
}
|
||||
if cfg.chroma == ChromaFormat::Yuv422 && !cfg.width.is_multiple_of(2) {
|
||||
return Err(Error::Input("4:2:2 requires even frame width".into()));
|
||||
}
|
||||
if !(10..=16).contains(&cfg.bit_depth) {
|
||||
return Err(Error::Input(format!("unsupported coded bit depth {}", cfg.bit_depth)));
|
||||
}
|
||||
if !cfg.profile.supports(cfg.chroma, cfg.bit_depth) {
|
||||
return Err(Error::Input(format!("profile {} does not support {:?} {}-bit", cfg.profile.name(), cfg.chroma, cfg.bit_depth)));
|
||||
}
|
||||
if cfg.tile_width_in_mbs == 0 || cfg.tile_height_in_mbs == 0 {
|
||||
return Err(Error::Input("tile dimensions in MBs must be non-zero".into()));
|
||||
}
|
||||
|
||||
let w_mbs = cfg.width.div_ceil(16);
|
||||
let h_mbs = cfg.height.div_ceil(16);
|
||||
let tile_cols = w_mbs.div_ceil(cfg.tile_width_in_mbs);
|
||||
let tile_rows = h_mbs.div_ceil(cfg.tile_height_in_mbs);
|
||||
if tile_cols > 20 || tile_rows > 20 {
|
||||
return Err(Error::Input(format!("tile grid {tile_cols}x{tile_rows} exceeds RFC 9924 limit of 20x20")));
|
||||
}
|
||||
|
||||
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
|
||||
let mut s = 0u32;
|
||||
while s < w_mbs {
|
||||
col_starts.push(s * 16);
|
||||
s += cfg.tile_width_in_mbs;
|
||||
}
|
||||
col_starts.push(w_mbs * 16);
|
||||
|
||||
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
|
||||
let mut s = 0u32;
|
||||
while s < h_mbs {
|
||||
row_starts.push(s * 16);
|
||||
s += cfg.tile_height_in_mbs;
|
||||
}
|
||||
row_starts.push(h_mbs * 16);
|
||||
|
||||
if let Some(qm) = &cfg.q_matrix {
|
||||
for c in 0..cfg.chroma.num_comps() {
|
||||
if qm[c].contains(&0) {
|
||||
return Err(Error::Input("q_matrix entries must be non-zero (1..=255)".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hdr = FrameHeader {
|
||||
profile_idc: cfg.profile.idc(),
|
||||
level_idc: cfg.level_idc,
|
||||
band_idc: cfg.band_idc.min(3),
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
chroma: cfg.chroma,
|
||||
bit_depth: cfg.bit_depth,
|
||||
capture_time_distance: 0,
|
||||
color_description_present: cfg.color_description_present,
|
||||
color: cfg.color,
|
||||
use_q_matrix: cfg.q_matrix.is_some(),
|
||||
q_matrix: cfg.q_matrix.unwrap_or([[DEFAULT_Q_MATRIX_VAL; 64]; 4]),
|
||||
tile_width_in_mbs: cfg.tile_width_in_mbs,
|
||||
tile_height_in_mbs: cfg.tile_height_in_mbs,
|
||||
tile_cols,
|
||||
tile_rows,
|
||||
col_starts,
|
||||
row_starts,
|
||||
tile_sizes_in_fh: None,
|
||||
header_bytes: 0,
|
||||
};
|
||||
|
||||
Ok(Self { cfg, hdr })
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &EncoderConfig {
|
||||
&self.cfg
|
||||
}
|
||||
|
||||
pub fn target_frame_bytes(&self) -> u32 {
|
||||
self.cfg.target_frame_bytes
|
||||
}
|
||||
|
||||
/// Encode one frame into an APV Access Unit (`b"aPv1"...`), suitable for MP4/MOV (`apv1`) or MKV (`V_APV`).
|
||||
pub fn encode(&mut self, frame: &Frame) -> Result<Vec<u8>> {
|
||||
if frame.width != self.cfg.width || frame.height != self.cfg.height {
|
||||
return Err(Error::Input(format!("frame is {}x{}, encoder is {}x{}", frame.width, frame.height, self.cfg.width, self.cfg.height)));
|
||||
}
|
||||
if frame.chroma != self.cfg.chroma {
|
||||
return Err(Error::Input(format!("frame chroma {:?} does not match encoder {:?}", frame.chroma, self.cfg.chroma)));
|
||||
}
|
||||
if !(8..=16).contains(&frame.bit_depth) {
|
||||
return Err(Error::Input(format!("unsupported input bit depth {}", frame.bit_depth)));
|
||||
}
|
||||
let n_luma = (frame.width as usize) * (frame.height as usize);
|
||||
let n_chroma = (frame.chroma_width() as usize) * (frame.chroma_height() as usize);
|
||||
if frame.y.len() != n_luma || frame.cb.len() != n_chroma || frame.cr.len() != n_chroma {
|
||||
return Err(Error::Input("plane buffer lengths do not match frame geometry".into()));
|
||||
}
|
||||
if self.cfg.chroma == ChromaFormat::Yuv4444 && frame.alpha.as_ref().is_none_or(|a| a.len() != n_luma) {
|
||||
return Err(Error::Input("4:4:4:4 profile requires an alpha plane of width * height samples".into()));
|
||||
}
|
||||
|
||||
let prepared = self.prepare_tiles(frame);
|
||||
let max_qp = (51 + self.hdr.qp_bd_offset()) as u8;
|
||||
let qp = match self.cfg.qp {
|
||||
Some(q) => q.min(max_qp),
|
||||
None => self.select_qp(&prepared, max_qp),
|
||||
};
|
||||
|
||||
let encoded_tiles: Vec<Vec<u8>> = {
|
||||
#[cfg(feature = "threads")]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
prepared.par_iter().map(|t| self.encode_tile(t, qp)).collect()
|
||||
}
|
||||
#[cfg(not(feature = "threads"))]
|
||||
{
|
||||
prepared.iter().map(|t| self.encode_tile(t, qp)).collect()
|
||||
}
|
||||
};
|
||||
|
||||
let mut hdr = self.hdr.clone();
|
||||
hdr.color = if !self.cfg.color_description_present {
|
||||
ColorInfo::default()
|
||||
} else if frame.color != ColorInfo::BT709 {
|
||||
frame.color
|
||||
} else {
|
||||
self.cfg.color
|
||||
};
|
||||
if self.cfg.write_tile_sizes_in_fh {
|
||||
hdr.tile_sizes_in_fh = Some(encoded_tiles.iter().map(|t| t.len() as u32).collect());
|
||||
}
|
||||
let fh_bytes = write_frame_header(&hdr);
|
||||
|
||||
let tiles_total_bytes: usize = encoded_tiles.iter().map(|t| 4 + t.len()).sum();
|
||||
let pbu_size = 4 + fh_bytes.len() + tiles_total_bytes;
|
||||
|
||||
let mdcv = frame.mastering_display.as_ref().or(self.cfg.mastering_display.as_ref());
|
||||
let cll = frame.content_light.as_ref().or(self.cfg.content_light.as_ref());
|
||||
let meta_pbu = write_metadata_pbu(1, mdcv, cll);
|
||||
let meta_total = meta_pbu.as_ref().map_or(0, |m| 4 + m.len());
|
||||
|
||||
let mut au = Vec::with_capacity(4 + 4 + pbu_size + meta_total);
|
||||
au.extend_from_slice(b"aPv1");
|
||||
// Primary frame PBU (pbu_type = 1, group_id = 1)
|
||||
au.extend_from_slice(&(pbu_size as u32).to_be_bytes());
|
||||
au.push(PBU_PRIMARY_FRAME);
|
||||
au.extend_from_slice(&1u16.to_be_bytes());
|
||||
au.push(0); // reserved_zero_8bits
|
||||
au.extend_from_slice(&fh_bytes);
|
||||
for t in &encoded_tiles {
|
||||
au.extend_from_slice(&(t.len() as u32).to_be_bytes());
|
||||
au.extend_from_slice(t);
|
||||
}
|
||||
if let Some(m) = meta_pbu {
|
||||
au.extend_from_slice(&(m.len() as u32).to_be_bytes());
|
||||
au.extend_from_slice(&m);
|
||||
}
|
||||
Ok(au)
|
||||
}
|
||||
|
||||
/// Encode one frame as a `raw_bitstream_access_unit()` (prefixed by `au_size: u32 BE`, RFC 9924 Appendix A).
|
||||
pub fn encode_raw_au(&mut self, frame: &Frame) -> Result<Vec<u8>> {
|
||||
let au = self.encode(frame)?;
|
||||
let mut raw = Vec::with_capacity(4 + au.len());
|
||||
raw.extend_from_slice(&(au.len() as u32).to_be_bytes());
|
||||
raw.extend_from_slice(&au);
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Build the `APVDecoderConfigurationRecord` payload (for ISOBMFF `apvC` or Matroska CodecPrivate).
|
||||
pub fn decoder_config_record(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(24);
|
||||
v.push(1); // configurationVersion = 1
|
||||
v.push(1); // number_of_configuration_entry = 1
|
||||
v.push(PBU_PRIMARY_FRAME); // pbu_type[0] = 1
|
||||
v.push(1); // number_of_frame_info[0] = 1
|
||||
let flags = if self.cfg.color_description_present { 0x02 } else { 0x00 } | 0x01; // capture_time_distance_ignored = 1
|
||||
v.push(flags);
|
||||
v.push(self.hdr.profile_idc);
|
||||
v.push(self.hdr.level_idc);
|
||||
v.push(self.hdr.band_idc);
|
||||
v.extend_from_slice(&self.hdr.width.to_be_bytes());
|
||||
v.extend_from_slice(&self.hdr.height.to_be_bytes());
|
||||
v.push((self.hdr.chroma.idc() << 4) | (self.hdr.bit_depth.saturating_sub(8) & 0x0F));
|
||||
v.push(0); // capture_time_distance = 0
|
||||
if self.cfg.color_description_present {
|
||||
v.push(self.cfg.color.primaries);
|
||||
v.push(self.cfg.color.transfer);
|
||||
v.push(self.cfg.color.matrix);
|
||||
v.push(if self.cfg.color.full_range { 0x80 } else { 0x00 });
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn prepare_tiles(&self, frame: &Frame) -> Vec<PreparedTile> {
|
||||
let num_tiles = self.hdr.num_tiles();
|
||||
let prepare_one = |tile_idx: usize| -> PreparedTile {
|
||||
let tx = tile_idx % (self.hdr.tile_cols as usize);
|
||||
let ty = tile_idx / (self.hdr.tile_cols as usize);
|
||||
let x0 = self.hdr.col_starts[tx];
|
||||
let x1 = self.hdr.col_starts[tx + 1];
|
||||
let y0 = self.hdr.row_starts[ty];
|
||||
let y1 = self.hdr.row_starts[ty + 1];
|
||||
let num_mb_cols = (x1 - x0) / 16;
|
||||
let num_mb_rows = (y1 - y0) / 16;
|
||||
let num_comps = self.hdr.chroma.num_comps();
|
||||
let mut comps = Vec::with_capacity(num_comps);
|
||||
|
||||
for c in 0..num_comps {
|
||||
let (plane, pw, ph, sub_w, sub_h) = match c {
|
||||
0 => (frame.y.as_slice(), frame.width as usize, frame.height as usize, 1u32, 1u32),
|
||||
1 => (
|
||||
frame.cb.as_slice(),
|
||||
frame.chroma_width() as usize,
|
||||
frame.chroma_height() as usize,
|
||||
self.hdr.chroma.sub_width_c(),
|
||||
self.hdr.chroma.sub_height_c(),
|
||||
),
|
||||
2 => (
|
||||
frame.cr.as_slice(),
|
||||
frame.chroma_width() as usize,
|
||||
frame.chroma_height() as usize,
|
||||
self.hdr.chroma.sub_width_c(),
|
||||
self.hdr.chroma.sub_height_c(),
|
||||
),
|
||||
_ => (frame.alpha.as_deref().unwrap_or(&[]), frame.width as usize, frame.height as usize, 1u32, 1u32),
|
||||
};
|
||||
let blk_w = (16 / sub_w) as usize;
|
||||
let blk_h = (16 / sub_h) as usize;
|
||||
let blocks_x = blk_w / 8;
|
||||
let blocks_y = blk_h / 8;
|
||||
let total_blocks = (num_mb_cols as usize) * (num_mb_rows as usize) * blocks_x * blocks_y;
|
||||
let mut blocks = Vec::with_capacity(total_blocks);
|
||||
|
||||
let coded_depth = self.hdr.bit_depth;
|
||||
let in_depth = frame.bit_depth;
|
||||
|
||||
for mb_y in 0..num_mb_rows {
|
||||
for mb_x in 0..num_mb_cols {
|
||||
let x_mb_c = ((x0 + mb_x * 16) / sub_w) as usize;
|
||||
let y_mb_c = ((y0 + mb_y * 16) / sub_h) as usize;
|
||||
for by in 0..blocks_y {
|
||||
for bx in 0..blocks_x {
|
||||
let bx0 = x_mb_c + bx * 8;
|
||||
let by0 = y_mb_c + by * 8;
|
||||
let mut samples = [0u16; 64];
|
||||
for ry in 0..8 {
|
||||
let sy = (by0 + ry).min(ph.saturating_sub(1));
|
||||
for rx in 0..8 {
|
||||
let sx = (bx0 + rx).min(pw.saturating_sub(1));
|
||||
let v = plane[sy * pw + sx];
|
||||
samples[ry * 8 + rx] = scale_sample(v, in_depth, coded_depth);
|
||||
}
|
||||
}
|
||||
blocks.push(fdct8x8(&samples, coded_depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
comps.push(PreparedTileComp { blocks });
|
||||
}
|
||||
PreparedTile { tile_idx, comps }
|
||||
};
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
(0..num_tiles).into_par_iter().map(prepare_one).collect()
|
||||
}
|
||||
#[cfg(not(feature = "threads"))]
|
||||
{
|
||||
(0..num_tiles).map(prepare_one).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn select_qp(&self, tiles: &[PreparedTile], max_qp: u8) -> u8 {
|
||||
let target_bytes = (self.cfg.target_frame_bytes as usize).max(64);
|
||||
let frame_bytes_at = |qp: u8| -> usize {
|
||||
let tile_bytes = |t: &PreparedTile| -> usize {
|
||||
let num_comps = t.comps.len();
|
||||
let mut sz = 4 + (5 + 5 * num_comps);
|
||||
for (c, comp) in t.comps.iter().enumerate() {
|
||||
let bits = self.estimate_comp_bits(comp, c, qp);
|
||||
sz += (bits.div_ceil(8) as usize).max(1);
|
||||
}
|
||||
sz
|
||||
};
|
||||
#[cfg(feature = "threads")]
|
||||
let sum: usize = {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().map(tile_bytes).sum()
|
||||
};
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let sum: usize = tiles.iter().map(tile_bytes).sum();
|
||||
24 + sum
|
||||
};
|
||||
|
||||
let mut lo = 0u8;
|
||||
let mut hi = max_qp;
|
||||
if frame_bytes_at(0) <= target_bytes {
|
||||
return 0;
|
||||
}
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if frame_bytes_at(mid) <= target_bytes {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
fn estimate_comp_bits(&self, comp: &PreparedTileComp, c: usize, qp: u8) -> u64 {
|
||||
let qm = &self.hdr.q_matrix[c];
|
||||
let depth = self.hdr.bit_depth;
|
||||
let mut prev_dc: i32 = 0;
|
||||
let mut prev_dc_diff: u32 = 20;
|
||||
let mut prev_1st_ac_level: u32 = 0;
|
||||
let mut bits: u64 = 0;
|
||||
|
||||
for f_blk in &comp.blocks {
|
||||
let coeff = quantize8x8(f_blk, qm, qp, depth);
|
||||
let dc = coeff[0] as i32;
|
||||
let diff = dc - prev_dc;
|
||||
let abs_dc_diff = diff.unsigned_abs();
|
||||
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||
bits += vlc_bit_len(abs_dc_diff, k_dc) as u64;
|
||||
if abs_dc_diff > 0 {
|
||||
bits += 1;
|
||||
}
|
||||
prev_dc = dc;
|
||||
prev_dc_diff = abs_dc_diff;
|
||||
|
||||
let mut scan_pos = 1usize;
|
||||
let mut first_ac = true;
|
||||
let mut prev_level = prev_1st_ac_level;
|
||||
let mut prev_run = 0u32;
|
||||
|
||||
while scan_pos < 64 {
|
||||
let mut run = 0u32;
|
||||
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
|
||||
run += 1;
|
||||
}
|
||||
let k_run = (prev_run >> 2).min(2);
|
||||
bits += vlc_bit_len(run, k_run) as u64;
|
||||
scan_pos += run as usize;
|
||||
prev_run = run;
|
||||
if scan_pos < 64 {
|
||||
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
|
||||
let abs_level = level.unsigned_abs() as u32;
|
||||
let k_lvl = (prev_level >> 2).min(4);
|
||||
bits += vlc_bit_len(abs_level - 1, k_lvl) as u64 + 1;
|
||||
scan_pos += 1;
|
||||
prev_level = abs_level;
|
||||
if first_ac {
|
||||
first_ac = false;
|
||||
prev_1st_ac_level = prev_level;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn encode_tile(&self, tile: &PreparedTile, qp: u8) -> Vec<u8> {
|
||||
let num_comps = tile.comps.len();
|
||||
let mut comp_payloads: Vec<Vec<u8>> = Vec::with_capacity(num_comps);
|
||||
let depth = self.hdr.bit_depth;
|
||||
|
||||
for (c, comp) in tile.comps.iter().enumerate() {
|
||||
let qm = &self.hdr.q_matrix[c];
|
||||
let mut w = BitWriter::new();
|
||||
let mut prev_dc: i32 = 0;
|
||||
let mut prev_dc_diff: u32 = 20;
|
||||
let mut prev_1st_ac_level: u32 = 0;
|
||||
|
||||
for f_blk in &comp.blocks {
|
||||
let coeff = quantize8x8(f_blk, qm, qp, depth);
|
||||
let dc = coeff[0] as i32;
|
||||
let diff = dc - prev_dc;
|
||||
let abs_dc_diff = diff.unsigned_abs();
|
||||
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||
write_vlc(&mut w, abs_dc_diff, k_dc);
|
||||
if abs_dc_diff > 0 {
|
||||
w.write_bit(diff < 0);
|
||||
}
|
||||
prev_dc = dc;
|
||||
prev_dc_diff = abs_dc_diff;
|
||||
|
||||
let mut scan_pos = 1usize;
|
||||
let mut first_ac = true;
|
||||
let mut prev_level = prev_1st_ac_level;
|
||||
let mut prev_run = 0u32;
|
||||
|
||||
while scan_pos < 64 {
|
||||
let mut run = 0u32;
|
||||
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
|
||||
run += 1;
|
||||
}
|
||||
let k_run = (prev_run >> 2).min(2);
|
||||
write_vlc(&mut w, run, k_run);
|
||||
scan_pos += run as usize;
|
||||
prev_run = run;
|
||||
if scan_pos < 64 {
|
||||
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
|
||||
let abs_level = level.unsigned_abs() as u32;
|
||||
let k_lvl = (prev_level >> 2).min(4);
|
||||
write_vlc(&mut w, abs_level - 1, k_lvl);
|
||||
w.write_bit(level < 0);
|
||||
scan_pos += 1;
|
||||
prev_level = abs_level;
|
||||
if first_ac {
|
||||
first_ac = false;
|
||||
prev_1st_ac_level = prev_level;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
comp_payloads.push(w.finish());
|
||||
}
|
||||
|
||||
// Write tile_header(tileIdx) (RFC 9924 §5.3.13)
|
||||
let tile_header_size = (5 + 5 * num_comps) as u16;
|
||||
let total_size = tile_header_size as usize + comp_payloads.iter().map(Vec::len).sum::<usize>();
|
||||
let mut out = Vec::with_capacity(total_size);
|
||||
out.extend_from_slice(&tile_header_size.to_be_bytes());
|
||||
out.extend_from_slice(&(tile.tile_idx as u16).to_be_bytes());
|
||||
for cp in &comp_payloads {
|
||||
out.extend_from_slice(&(cp.len() as u32).to_be_bytes());
|
||||
}
|
||||
for _ in 0..num_comps {
|
||||
out.push(qp);
|
||||
}
|
||||
out.push(0); // reserved_zero_8bits
|
||||
for cp in comp_payloads {
|
||||
out.extend_from_slice(&cp);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn scale_sample(v: u16, from_depth: u8, to_depth: u8) -> u16 {
|
||||
let max_in = (1u32 << from_depth) - 1;
|
||||
let v = (v as u32).min(max_in);
|
||||
if to_depth == from_depth {
|
||||
v as u16
|
||||
} else if to_depth > from_depth {
|
||||
let shift = (to_depth - from_depth) as u32;
|
||||
((v << shift) | (v >> ((from_depth as u32) - shift))) as u16
|
||||
} else {
|
||||
let shift = (from_depth - to_depth) as u32;
|
||||
let round = 1u32 << (shift - 1);
|
||||
let max_out = (1u32 << to_depth) - 1;
|
||||
(((v + round) >> shift).min(max_out)) as u16
|
||||
}
|
||||
}
|
||||
|
|
@ -1,460 +0,0 @@
|
|||
//! RFC 9924 §5.3 and §8 syntax structures: Access Unit, PBU, Frame Header, Tile Info, and Metadata.
|
||||
|
||||
use filmcraft_bitstream::{BitReader, BitWriter};
|
||||
|
||||
use crate::tables::DEFAULT_Q_MATRIX_VAL;
|
||||
use crate::{ChromaFormat, ColorInfo, ContentLightLevel, Error, MasteringDisplay, Result};
|
||||
|
||||
/// Four-character signature `'aPv1'` (`0x61507631`) at the start of every APV Access Unit (RFC 9924 §5.3.1).
|
||||
pub const AU_SIGNATURE: [u8; 4] = *b"aPv1";
|
||||
|
||||
/// Primitive Bitstream Unit (PBU) types (RFC 9924 §5.3.3 Table 3).
|
||||
pub const PBU_PRIMARY_FRAME: u8 = 1;
|
||||
pub const PBU_NON_PRIMARY_FRAME: u8 = 2;
|
||||
pub const PBU_PREVIEW_FRAME: u8 = 25;
|
||||
pub const PBU_DEPTH_FRAME: u8 = 26;
|
||||
pub const PBU_ALPHA_FRAME: u8 = 27;
|
||||
pub const PBU_AU_INFO: u8 = 65;
|
||||
pub const PBU_METADATA: u8 = 66;
|
||||
pub const PBU_FILLER: u8 = 67;
|
||||
|
||||
/// Maximum supported frame dimension in luma samples (guards against hostile allocations).
|
||||
pub const MAX_DIMENSION: u32 = 16384;
|
||||
|
||||
/// Maximum tiles per axis (RFC 9924 §9.4.1 specifies 20; we allow up to 64 for robustness).
|
||||
pub const MAX_TILE_DIM: u32 = 64;
|
||||
|
||||
/// Parsed `frame_header()` and derived tile geometry (RFC 9924 §5.3.5–5.3.8).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FrameHeader {
|
||||
pub profile_idc: u8,
|
||||
pub level_idc: u8,
|
||||
pub band_idc: u8,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub chroma: ChromaFormat,
|
||||
pub bit_depth: u8,
|
||||
pub capture_time_distance: u8,
|
||||
pub color_description_present: bool,
|
||||
pub color: ColorInfo,
|
||||
pub use_q_matrix: bool,
|
||||
/// Per-component 8x8 quantization matrix in raster order (`y * 8 + x`).
|
||||
pub q_matrix: [[u8; 64]; 4],
|
||||
pub tile_width_in_mbs: u32,
|
||||
pub tile_height_in_mbs: u32,
|
||||
pub tile_cols: u32,
|
||||
pub tile_rows: u32,
|
||||
/// Tile column start positions in luma samples (`len == tile_cols + 1`).
|
||||
pub col_starts: Vec<u32>,
|
||||
/// Tile row start positions in luma samples (`len == tile_rows + 1`).
|
||||
pub row_starts: Vec<u32>,
|
||||
pub tile_sizes_in_fh: Option<Vec<u32>>,
|
||||
/// Byte offset within the PBU payload where `frame_header()` ends (start of the tile loop).
|
||||
pub header_bytes: usize,
|
||||
}
|
||||
|
||||
impl FrameHeader {
|
||||
pub fn frame_width_in_mbs(&self) -> u32 {
|
||||
self.width.div_ceil(16)
|
||||
}
|
||||
|
||||
pub fn frame_height_in_mbs(&self) -> u32 {
|
||||
self.height.div_ceil(16)
|
||||
}
|
||||
|
||||
pub fn num_tiles(&self) -> usize {
|
||||
(self.tile_cols as usize) * (self.tile_rows as usize)
|
||||
}
|
||||
|
||||
pub fn qp_bd_offset(&self) -> i32 {
|
||||
(self.bit_depth.saturating_sub(8) as i32) * 6
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed PBU slice inside an Access Unit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PbuSlice<'a> {
|
||||
pub pbu_type: u8,
|
||||
pub group_id: u16,
|
||||
/// Payload after the 4-byte `pbu_header()`.
|
||||
pub payload: &'a [u8],
|
||||
}
|
||||
|
||||
/// Unwrap an Access Unit byte slice: accepts either a direct `access_unit` starting with `'aPv1'`
|
||||
/// or a `raw_bitstream_access_unit` prefixed with a 32-bit big-endian `au_size` (RFC 9924 Appendix A).
|
||||
pub fn unwrap_au(data: &[u8]) -> Result<&[u8]> {
|
||||
if data.len() < 4 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
if data[..4] == AU_SIGNATURE {
|
||||
return Ok(data);
|
||||
}
|
||||
if data.len() >= 8 && data[4..8] == AU_SIGNATURE {
|
||||
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if au_size < 4 || au_size == 0xFFFF_FFFF {
|
||||
return Err(Error::Invalid("invalid au_size in raw bitstream"));
|
||||
}
|
||||
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
|
||||
return data.get(4..end).ok_or(Error::Truncated);
|
||||
}
|
||||
Err(Error::Invalid("missing aPv1 access unit signature"))
|
||||
}
|
||||
|
||||
/// Split a raw `.apv` bitstream (`raw_bitstream_access_unit()` sequence per RFC 9924 Appendix A)
|
||||
/// into individual Access Unit slices (each starting with `'aPv1'`).
|
||||
pub fn split_raw_bitstream(mut data: &[u8]) -> Result<Vec<&[u8]>> {
|
||||
if data.is_empty() {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
// If the buffer starts directly with 'aPv1', treat the entire buffer as a single AU.
|
||||
if data.len() >= 4 && data[..4] == AU_SIGNATURE {
|
||||
return Ok(vec![data]);
|
||||
}
|
||||
let mut aus = Vec::new();
|
||||
while !data.is_empty() {
|
||||
if data.len() < 8 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if au_size < 4 || au_size == 0xFFFF_FFFF {
|
||||
return Err(Error::Invalid("invalid au_size in raw bitstream"));
|
||||
}
|
||||
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
|
||||
let au = data.get(4..end).ok_or(Error::Truncated)?;
|
||||
if au[..4] != AU_SIGNATURE {
|
||||
return Err(Error::Invalid("missing aPv1 signature in raw bitstream AU"));
|
||||
}
|
||||
aus.push(au);
|
||||
data = &data[end..];
|
||||
}
|
||||
if aus.is_empty() {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
Ok(aus)
|
||||
}
|
||||
|
||||
/// Parse the PBU list of a single Access Unit (RFC 9924 §5.3.1–5.3.3).
|
||||
/// PBUs with `reserved_zero_8bits > 0` are ignored as required by RFC 9924 §5.3.3.
|
||||
pub fn parse_au_pbus(data: &[u8]) -> Result<Vec<PbuSlice<'_>>> {
|
||||
let au = unwrap_au(data)?;
|
||||
if au.len() < 8 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let mut pos = 4usize;
|
||||
let mut pbus = Vec::new();
|
||||
while pos < au.len() {
|
||||
let sz_bytes = au.get(pos..pos + 4).ok_or(Error::Truncated)?;
|
||||
let pbu_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
|
||||
if pbu_size == 0 || pbu_size == 0xFFFF_FFFF {
|
||||
return Err(Error::Invalid("invalid pbu_size"));
|
||||
}
|
||||
if pbu_size < 4 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
pos += 4;
|
||||
let pbu_end = pos.checked_add(pbu_size).ok_or(Error::Truncated)?;
|
||||
let pbu = au.get(pos..pbu_end).ok_or(Error::Truncated)?;
|
||||
pos = pbu_end;
|
||||
|
||||
let pbu_type = pbu[0];
|
||||
let group_id = u16::from_be_bytes([pbu[1], pbu[2]]);
|
||||
let reserved_zero_8bits = pbu[3];
|
||||
if reserved_zero_8bits > 0 {
|
||||
// RFC 9924 §5.3.3: Decoders MUST ignore PBU with reserved_zero_8bits > 0.
|
||||
continue;
|
||||
}
|
||||
if group_id == 0xFFFF || (group_id == 0 && pbu_type <= 64) {
|
||||
return Err(Error::Invalid("invalid PBU group_id"));
|
||||
}
|
||||
pbus.push(PbuSlice { pbu_type, group_id, payload: &pbu[4..] });
|
||||
}
|
||||
Ok(pbus)
|
||||
}
|
||||
|
||||
/// Read `byte_alignment()` and verify all alignment bits are zero (RFC 9924 §5.3.17).
|
||||
pub fn read_byte_alignment(r: &mut BitReader) -> Result<()> {
|
||||
while !r.is_byte_aligned() {
|
||||
let bit = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
if bit {
|
||||
return Err(Error::Invalid("non-zero alignment bit"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `frame_header()` from the payload of a frame PBU (after `pbu_header()`), per RFC 9924 §5.3.5–5.3.8.
|
||||
/// Returns `Ok(None)` if any `reserved_zero_*` field is non-zero (RFC 9924 §5.3.5 requires ignoring the PBU).
|
||||
pub fn parse_frame_header(payload: &[u8]) -> Result<Option<FrameHeader>> {
|
||||
let mut r = BitReader::new(payload);
|
||||
|
||||
// frame_info() (RFC 9924 §5.3.6)
|
||||
let profile_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let level_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let band_idc = r.read_bits(3).map_err(|_| Error::Truncated)? as u8;
|
||||
let reserved_zero_5bits = r.read_bits(5).map_err(|_| Error::Truncated)? as u8;
|
||||
let width = r.read_bits(24).map_err(|_| Error::Truncated)?;
|
||||
let height = r.read_bits(24).map_err(|_| Error::Truncated)?;
|
||||
let chroma_format_idc = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
|
||||
let bit_depth_minus8 = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
|
||||
let capture_time_distance = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let fi_reserved_8 = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
|
||||
// frame_header() (RFC 9924 §5.3.5)
|
||||
let fh_reserved_8_a = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
if reserved_zero_5bits > 0 || fi_reserved_8 > 0 || fh_reserved_8_a > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION {
|
||||
return Err(Error::Invalid("unsupported frame dimensions"));
|
||||
}
|
||||
let chroma = ChromaFormat::from_idc(chroma_format_idc).ok_or(Error::Invalid("invalid chroma_format_idc"))?;
|
||||
if chroma == ChromaFormat::Yuv422 && !width.is_multiple_of(2) {
|
||||
return Err(Error::Invalid("4:2:2 frame_width must be even"));
|
||||
}
|
||||
if !(2..=8).contains(&bit_depth_minus8) {
|
||||
return Err(Error::Invalid("bit_depth_minus8 out of range 2..=8"));
|
||||
}
|
||||
let bit_depth = bit_depth_minus8 + 8;
|
||||
|
||||
let color_description_present = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
let color = if color_description_present {
|
||||
let primaries = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let transfer = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let matrix = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let full_range = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
ColorInfo { primaries, transfer, matrix, full_range }
|
||||
} else {
|
||||
ColorInfo::default()
|
||||
};
|
||||
|
||||
let use_q_matrix = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
let mut q_matrix = [[DEFAULT_Q_MATRIX_VAL; 64]; 4];
|
||||
if use_q_matrix {
|
||||
for c in 0..chroma.num_comps() {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
let q = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
if q == 0 {
|
||||
return Err(Error::Invalid("q_matrix entry 0 is reserved"));
|
||||
}
|
||||
q_matrix[c][y * 8 + x] = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tile_info() (RFC 9924 §5.3.8)
|
||||
let tile_width_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
|
||||
let tile_height_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
|
||||
if tile_width_in_mbs == 0 || tile_height_in_mbs == 0 {
|
||||
return Err(Error::Invalid("tile dimensions in MBs must be non-zero"));
|
||||
}
|
||||
|
||||
let w_mbs = width.div_ceil(16);
|
||||
let h_mbs = height.div_ceil(16);
|
||||
let tile_cols = w_mbs.div_ceil(tile_width_in_mbs);
|
||||
let tile_rows = h_mbs.div_ceil(tile_height_in_mbs);
|
||||
if tile_cols == 0 || tile_rows == 0 || tile_cols > MAX_TILE_DIM || tile_rows > MAX_TILE_DIM {
|
||||
return Err(Error::Invalid("too many tiles in frame"));
|
||||
}
|
||||
|
||||
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
|
||||
let mut start_mb = 0u32;
|
||||
while start_mb < w_mbs {
|
||||
col_starts.push(start_mb * 16);
|
||||
start_mb = start_mb.saturating_add(tile_width_in_mbs);
|
||||
}
|
||||
col_starts.push(w_mbs * 16);
|
||||
|
||||
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
|
||||
let mut start_mb = 0u32;
|
||||
while start_mb < h_mbs {
|
||||
row_starts.push(start_mb * 16);
|
||||
start_mb = start_mb.saturating_add(tile_height_in_mbs);
|
||||
}
|
||||
row_starts.push(h_mbs * 16);
|
||||
|
||||
let num_tiles = (tile_cols as usize) * (tile_rows as usize);
|
||||
let tile_size_present_in_fh = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
let tile_sizes_in_fh = if tile_size_present_in_fh {
|
||||
let mut sizes = Vec::with_capacity(num_tiles);
|
||||
for _ in 0..num_tiles {
|
||||
let sz = r.read_bits(32).map_err(|_| Error::Truncated)?;
|
||||
if sz == 0 {
|
||||
return Err(Error::Invalid("tile_size_in_fh cannot be 0"));
|
||||
}
|
||||
sizes.push(sz);
|
||||
}
|
||||
Some(sizes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let fh_reserved_8_b = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
if fh_reserved_8_b > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
read_byte_alignment(&mut r)?;
|
||||
|
||||
Ok(Some(FrameHeader {
|
||||
profile_idc,
|
||||
level_idc,
|
||||
band_idc,
|
||||
width,
|
||||
height,
|
||||
chroma,
|
||||
bit_depth,
|
||||
capture_time_distance,
|
||||
color_description_present,
|
||||
color,
|
||||
use_q_matrix,
|
||||
q_matrix,
|
||||
tile_width_in_mbs,
|
||||
tile_height_in_mbs,
|
||||
tile_cols,
|
||||
tile_rows,
|
||||
col_starts,
|
||||
row_starts,
|
||||
tile_sizes_in_fh,
|
||||
header_bytes: r.byte_pos(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Write `frame_header()` to a byte vector (RFC 9924 §5.3.5–5.3.8).
|
||||
pub fn write_frame_header(h: &FrameHeader) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
// frame_info()
|
||||
w.write_bits(h.profile_idc as u32, 8);
|
||||
w.write_bits(h.level_idc as u32, 8);
|
||||
w.write_bits((h.band_idc & 7) as u32, 3);
|
||||
w.write_bits(0, 5); // reserved_zero_5bits
|
||||
w.write_bits(h.width, 24);
|
||||
w.write_bits(h.height, 24);
|
||||
w.write_bits(h.chroma.idc() as u32, 4);
|
||||
w.write_bits(h.bit_depth.saturating_sub(8) as u32, 4);
|
||||
w.write_bits(h.capture_time_distance as u32, 8);
|
||||
w.write_bits(0, 8); // reserved_zero_8bits
|
||||
|
||||
// frame_header()
|
||||
w.write_bits(0, 8); // reserved_zero_8bits
|
||||
w.write_bit(h.color_description_present);
|
||||
if h.color_description_present {
|
||||
w.write_bits(h.color.primaries as u32, 8);
|
||||
w.write_bits(h.color.transfer as u32, 8);
|
||||
w.write_bits(h.color.matrix as u32, 8);
|
||||
w.write_bit(h.color.full_range);
|
||||
}
|
||||
w.write_bit(h.use_q_matrix);
|
||||
if h.use_q_matrix {
|
||||
for c in 0..h.chroma.num_comps() {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
w.write_bits(h.q_matrix[c][y * 8 + x].max(1) as u32, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// tile_info()
|
||||
w.write_bits(h.tile_width_in_mbs, 20);
|
||||
w.write_bits(h.tile_height_in_mbs, 20);
|
||||
if let Some(sizes) = &h.tile_sizes_in_fh {
|
||||
w.write_bit(true);
|
||||
for &sz in sizes {
|
||||
w.write_bits(sz, 32);
|
||||
}
|
||||
} else {
|
||||
w.write_bit(false);
|
||||
}
|
||||
w.write_bits(0, 8); // reserved_zero_8bits
|
||||
w.finish()
|
||||
}
|
||||
|
||||
/// Parse a `metadata()` PBU payload (after `pbu_header()`), extracting MDCV (`payloadType == 5`)
|
||||
/// and CLL (`payloadType == 6`) if present (RFC 9924 §5.3.10 & §8).
|
||||
pub fn parse_metadata_pbu(payload: &[u8], mdcv: &mut Option<MasteringDisplay>, cll: &mut Option<ContentLightLevel>) -> Result<()> {
|
||||
if payload.len() < 4 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let metadata_size = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
|
||||
let body = payload.get(4..4usize.checked_add(metadata_size).ok_or(Error::Truncated)?).ok_or(Error::Truncated)?;
|
||||
let mut pos = 0usize;
|
||||
while pos < body.len() {
|
||||
let mut payload_type = 0u32;
|
||||
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
|
||||
payload_type = payload_type.checked_add(255).ok_or(Error::Invalid("metadata payloadType overflow"))?;
|
||||
pos += 1;
|
||||
}
|
||||
payload_type = payload_type.checked_add(*body.get(pos).ok_or(Error::Truncated)? as u32).ok_or(Error::Invalid("metadata payloadType overflow"))?;
|
||||
pos += 1;
|
||||
|
||||
let mut payload_size = 0usize;
|
||||
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
|
||||
payload_size = payload_size.checked_add(255).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
|
||||
pos += 1;
|
||||
}
|
||||
payload_size = payload_size.checked_add(*body.get(pos).ok_or(Error::Truncated)? as usize).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
|
||||
pos += 1;
|
||||
|
||||
let end = pos.checked_add(payload_size).ok_or(Error::Truncated)?;
|
||||
let item = body.get(pos..end).ok_or(Error::Truncated)?;
|
||||
pos = end;
|
||||
|
||||
match payload_type {
|
||||
5 if item.len() >= 24 => {
|
||||
let u16_at = |i: usize| u16::from_be_bytes([item[i], item[i + 1]]);
|
||||
let u32_at = |i: usize| u32::from_be_bytes([item[i], item[i + 1], item[i + 2], item[i + 3]]);
|
||||
*mdcv = Some(MasteringDisplay {
|
||||
primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))],
|
||||
white_point: (u16_at(12), u16_at(14)),
|
||||
max_luminance: u32_at(16),
|
||||
min_luminance: u32_at(20),
|
||||
});
|
||||
}
|
||||
6 if item.len() >= 4 => {
|
||||
*cll = Some(ContentLightLevel { max_cll: u16::from_be_bytes([item[0], item[1]]), max_fall: u16::from_be_bytes([item[2], item[3]]) });
|
||||
}
|
||||
// Filler metadata (§8.2.1): must be 0xFF bytes
|
||||
10 if item.iter().any(|&b| b != 0xFF) => {
|
||||
return Err(Error::Invalid("non-0xFF byte in filler metadata"));
|
||||
}
|
||||
_ => {
|
||||
// RFC 9924 §10: A decoder MUST NOT try to process metadata whose type is not recognized.
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a `metadata()` PBU (including its 4-byte `pbu_header`) carrying MDCV and/or CLL metadata.
|
||||
pub fn write_metadata_pbu(group_id: u16, mdcv: Option<&MasteringDisplay>, cll: Option<&ContentLightLevel>) -> Option<Vec<u8>> {
|
||||
if mdcv.is_none() && cll.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
if let Some(m) = mdcv {
|
||||
body.push(5); // payloadType = 5 (MDCV)
|
||||
body.push(24); // payloadSize = 24
|
||||
for &(x, y) in &m.primaries {
|
||||
body.extend_from_slice(&x.to_be_bytes());
|
||||
body.extend_from_slice(&y.to_be_bytes());
|
||||
}
|
||||
body.extend_from_slice(&m.white_point.0.to_be_bytes());
|
||||
body.extend_from_slice(&m.white_point.1.to_be_bytes());
|
||||
body.extend_from_slice(&m.max_luminance.to_be_bytes());
|
||||
body.extend_from_slice(&m.min_luminance.to_be_bytes());
|
||||
}
|
||||
if let Some(c) = cll {
|
||||
body.push(6); // payloadType = 6 (CLL)
|
||||
body.push(4); // payloadSize = 4
|
||||
body.extend_from_slice(&c.max_cll.to_be_bytes());
|
||||
body.extend_from_slice(&c.max_fall.to_be_bytes());
|
||||
}
|
||||
let mut pbu = Vec::with_capacity(8 + body.len());
|
||||
pbu.push(PBU_METADATA);
|
||||
pbu.extend_from_slice(&group_id.to_be_bytes());
|
||||
pbu.push(0); // reserved_zero_8bits
|
||||
pbu.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
pbu.extend_from_slice(&body);
|
||||
Some(pbu)
|
||||
}
|
||||
|
|
@ -1,329 +0,0 @@
|
|||
//! Clean-room Advanced Professional Video (APV) codec, implemented strictly from IETF RFC 9924
|
||||
//! (February 2026).
|
||||
//!
|
||||
//! - **Decoder** ([`decode_frame`], [`decode_frame_with`], [`decode_frame_into`], [`probe`]):
|
||||
//! every profile in RFC 9924 §9 (`422-10`, `422-12`, `444-10`, `444-12`, `4444-10`, `4444-12`,
|
||||
//! `400-10`) at 10–16 bits per sample, custom quantization matrices, multi-tile parallel decoding
|
||||
//! (behind the `threads` feature), 4:4:4:4 and auxiliary alpha PBU (`pbu_type == 27`) support,
|
||||
//! and HDR static metadata (`MDCV` and `CLL`, RFC 9924 §8).
|
||||
//! - **Encoder** ([`Encoder`]): progressive intra frame encoding across all 7 profiles with fixed-QP
|
||||
//! or frame-budget rate control, plus `APVDecoderConfigurationRecord` (`apvC`) generation.
|
||||
//!
|
||||
//! See the crate README for specification edition, accuracy against external oracles, and design notes.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod dct;
|
||||
mod decode;
|
||||
mod encode;
|
||||
pub mod header;
|
||||
pub mod tables;
|
||||
|
||||
pub use decode::{DecodeOptions, decode_frame, decode_frame_into, decode_frame_with, probe};
|
||||
pub use encode::{Encoder, EncoderConfig};
|
||||
pub use header::{AU_SIGNATURE, FrameHeader, split_raw_bitstream, unwrap_au};
|
||||
|
||||
/// APV codec errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("truncated APV bitstream")]
|
||||
Truncated,
|
||||
#[error("invalid APV bitstream: {0}")]
|
||||
Invalid(&'static str),
|
||||
#[error("corrupt APV tile {0}")]
|
||||
CorruptTile(usize),
|
||||
#[error("invalid APV encoder input: {0}")]
|
||||
Input(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Chroma format (`chroma_format_idc` in RFC 9924 §4.1 Table 2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ChromaFormat {
|
||||
/// `chroma_format_idc == 0`: 4:0:0 monochrome (1 component).
|
||||
Monochrome,
|
||||
/// `chroma_format_idc == 2`: 4:2:2 YCbCr (3 components, `SubWidthC = 2, SubHeightC = 1`).
|
||||
Yuv422,
|
||||
/// `chroma_format_idc == 3`: 4:4:4 YCbCr (3 components, `SubWidthC = 1, SubHeightC = 1`).
|
||||
Yuv444,
|
||||
/// `chroma_format_idc == 4`: 4:4:4:4 YCbCrA (4 components, `SubWidthC = 1, SubHeightC = 1`).
|
||||
Yuv4444,
|
||||
}
|
||||
|
||||
impl ChromaFormat {
|
||||
pub fn from_idc(idc: u8) -> Option<Self> {
|
||||
match idc {
|
||||
0 => Some(Self::Monochrome),
|
||||
2 => Some(Self::Yuv422),
|
||||
3 => Some(Self::Yuv444),
|
||||
4 => Some(Self::Yuv4444),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn idc(self) -> u8 {
|
||||
match self {
|
||||
Self::Monochrome => 0,
|
||||
Self::Yuv422 => 2,
|
||||
Self::Yuv444 => 3,
|
||||
Self::Yuv4444 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num_comps(self) -> usize {
|
||||
match self {
|
||||
Self::Monochrome => 1,
|
||||
Self::Yuv422 | Self::Yuv444 => 3,
|
||||
Self::Yuv4444 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sub_width_c(self) -> u32 {
|
||||
match self {
|
||||
Self::Yuv422 => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sub_height_c(self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
pub fn chroma_width(self, width: u32) -> u32 {
|
||||
match self {
|
||||
Self::Monochrome => 0,
|
||||
Self::Yuv422 => width.div_ceil(2),
|
||||
Self::Yuv444 | Self::Yuv4444 => width,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chroma_height(self, height: u32) -> u32 {
|
||||
match self {
|
||||
Self::Monochrome => 0,
|
||||
_ => height,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_alpha(self) -> bool {
|
||||
self == Self::Yuv4444
|
||||
}
|
||||
}
|
||||
|
||||
/// APV profiles defined in RFC 9924 §9.3.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Profile {
|
||||
/// `profile_idc == 33`: 422-10 profile (4:2:2, 10-bit).
|
||||
#[default]
|
||||
P422_10,
|
||||
/// `profile_idc == 44`: 422-12 profile (4:2:2, 10–12-bit).
|
||||
P422_12,
|
||||
/// `profile_idc == 55`: 444-10 profile (4:2:2..4:4:4, 10-bit).
|
||||
P444_10,
|
||||
/// `profile_idc == 66`: 444-12 profile (4:2:2..4:4:4, 10–12-bit).
|
||||
P444_12,
|
||||
/// `profile_idc == 77`: 4444-10 profile (4:2:2..4:4:4:4, 10-bit).
|
||||
P4444_10,
|
||||
/// `profile_idc == 88`: 4444-12 profile (4:2:2..4:4:4:4, 10–12-bit).
|
||||
P4444_12,
|
||||
/// `profile_idc == 99`: 400-10 profile (4:0:0, 10-bit).
|
||||
P400_10,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub const ALL: [Profile; 7] =
|
||||
[Profile::P422_10, Profile::P422_12, Profile::P444_10, Profile::P444_12, Profile::P4444_10, Profile::P4444_12, Profile::P400_10];
|
||||
|
||||
pub fn idc(self) -> u8 {
|
||||
match self {
|
||||
Self::P422_10 => 33,
|
||||
Self::P422_12 => 44,
|
||||
Self::P444_10 => 55,
|
||||
Self::P444_12 => 66,
|
||||
Self::P4444_10 => 77,
|
||||
Self::P4444_12 => 88,
|
||||
Self::P400_10 => 99,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_idc(idc: u8) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| p.idc() == idc)
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::P422_10 => "APV 422-10",
|
||||
Self::P422_12 => "APV 422-12",
|
||||
Self::P444_10 => "APV 444-10",
|
||||
Self::P444_12 => "APV 444-12",
|
||||
Self::P4444_10 => "APV 4444-10",
|
||||
Self::P4444_12 => "APV 4444-12",
|
||||
Self::P400_10 => "APV 400-10",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::P422_10 => "422-10",
|
||||
Self::P422_12 => "422-12",
|
||||
Self::P444_10 => "444-10",
|
||||
Self::P444_12 => "444-12",
|
||||
Self::P4444_10 => "4444-10",
|
||||
Self::P4444_12 => "4444-12",
|
||||
Self::P400_10 => "400-10",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_chroma(self) -> ChromaFormat {
|
||||
match self {
|
||||
Self::P422_10 | Self::P422_12 => ChromaFormat::Yuv422,
|
||||
Self::P444_10 | Self::P444_12 => ChromaFormat::Yuv444,
|
||||
Self::P4444_10 | Self::P4444_12 => ChromaFormat::Yuv4444,
|
||||
Self::P400_10 => ChromaFormat::Monochrome,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_bit_depth(self) -> u8 {
|
||||
match self {
|
||||
Self::P422_12 | Self::P444_12 | Self::P4444_12 => 12,
|
||||
_ => 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether `(chroma, bit_depth)` conforms to this profile per RFC 9924 §9.3.
|
||||
pub fn supports(self, chroma: ChromaFormat, bit_depth: u8) -> bool {
|
||||
let c = chroma.idc();
|
||||
let b = bit_depth.saturating_sub(8);
|
||||
match self {
|
||||
Self::P422_10 => c == 2 && b == 2,
|
||||
Self::P422_12 => c == 2 && (2..=4).contains(&b),
|
||||
Self::P444_10 => (2..=3).contains(&c) && b == 2,
|
||||
Self::P444_12 => (2..=3).contains(&c) && (2..=4).contains(&b),
|
||||
Self::P4444_10 => (2..=4).contains(&c) && b == 2,
|
||||
Self::P4444_12 => (2..=4).contains(&c) && (2..=4).contains(&b),
|
||||
Self::P400_10 => c == 0 && b == 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nominal target bytes per frame for `(width, height)`.
|
||||
pub fn nominal_frame_bytes(self, width: u32, height: u32) -> u32 {
|
||||
let bpp = match self {
|
||||
Self::P400_10 => 1.5,
|
||||
Self::P422_10 => 3.0,
|
||||
Self::P422_12 => 3.8,
|
||||
Self::P444_10 => 4.2,
|
||||
Self::P444_12 => 5.0,
|
||||
Self::P4444_10 => 5.2,
|
||||
Self::P4444_12 => 6.2,
|
||||
};
|
||||
let bytes = ((width as f64) * (height as f64) * bpp / 8.0).round() as u32;
|
||||
bytes.max(512)
|
||||
}
|
||||
|
||||
/// Nominal data rate in Mbit/s at 1920x1080 29.97 fps.
|
||||
pub fn nominal_mbps_1080p30(self) -> f64 {
|
||||
(self.nominal_frame_bytes(1920, 1080) as f64) * 8.0 * 29.97 / 1e6
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable profile name from `profile_idc` (e.g. `"APV 422-10"`).
|
||||
pub fn profile_name(profile_idc: u8) -> String {
|
||||
Profile::from_idc(profile_idc).map(|p| p.name().to_string()).unwrap_or_else(|| format!("APV (profile {profile_idc})"))
|
||||
}
|
||||
|
||||
/// Colour description from `frame_header()` (ITU-T H.273 code points, RFC 9924 §5.3.5).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ColorInfo {
|
||||
pub primaries: u8,
|
||||
pub transfer: u8,
|
||||
pub matrix: u8,
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
impl Default for ColorInfo {
|
||||
/// Inferred values when `color_description_present_flag == 0` (RFC 9924 §5.3.5: 2, 2, 2, 0).
|
||||
fn default() -> Self {
|
||||
Self { primaries: 2, transfer: 2, matrix: 2, full_range: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorInfo {
|
||||
pub const BT709: Self = Self { primaries: 1, transfer: 1, matrix: 1, full_range: false };
|
||||
pub const BT2020_PQ: Self = Self { primaries: 9, transfer: 16, matrix: 9, full_range: false };
|
||||
pub const BT2020_HLG: Self = Self { primaries: 9, transfer: 18, matrix: 9, full_range: false };
|
||||
}
|
||||
|
||||
/// Mastering Display Color Volume metadata (`payloadType == 5`, RFC 9924 §8.2.3).
|
||||
///
|
||||
/// Chromaticities are in 0.16 fixed-point (1/65536), order `[R, G, B]`;
|
||||
/// `max_luminance` is 24.8 fixed-point cd/m²; `min_luminance` is 18.14 fixed-point cd/m².
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct MasteringDisplay {
|
||||
pub primaries: [(u16, u16); 3],
|
||||
pub white_point: (u16, u16),
|
||||
pub max_luminance: u32,
|
||||
pub min_luminance: u32,
|
||||
}
|
||||
|
||||
/// Content Light-Level Information metadata (`payloadType == 6`, RFC 9924 §8.2.4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ContentLightLevel {
|
||||
pub max_cll: u16,
|
||||
pub max_fall: u16,
|
||||
}
|
||||
|
||||
/// A decoded (or to-be-encoded) APV frame: planar `u16` samples, rows packed without padding.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub chroma: ChromaFormat,
|
||||
/// Significant bits per sample (`10..=16`, or `8..=16` when rescaled via [`DecodeOptions`]).
|
||||
pub bit_depth: u8,
|
||||
pub y: Vec<u16>,
|
||||
pub cb: Vec<u16>,
|
||||
pub cr: Vec<u16>,
|
||||
pub alpha: Option<Vec<u16>>,
|
||||
pub color: ColorInfo,
|
||||
pub profile_idc: u8,
|
||||
pub level_idc: u8,
|
||||
pub band_idc: u8,
|
||||
pub mastering_display: Option<MasteringDisplay>,
|
||||
pub content_light: Option<ContentLightLevel>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Allocate a black video-range frame.
|
||||
pub fn new(width: u32, height: u32, chroma: ChromaFormat, bit_depth: u8, with_alpha: bool) -> Self {
|
||||
let n = (width as usize) * (height as usize);
|
||||
let cw = chroma.chroma_width(width) as usize;
|
||||
let ch = chroma.chroma_height(height) as usize;
|
||||
let cn = cw * ch;
|
||||
let shift = bit_depth.saturating_sub(8) as u32;
|
||||
let has_alpha = with_alpha || chroma == ChromaFormat::Yuv4444;
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
chroma,
|
||||
bit_depth,
|
||||
y: vec![(16u32 << shift) as u16; n],
|
||||
cb: vec![(128u32 << shift) as u16; cn],
|
||||
cr: vec![(128u32 << shift) as u16; cn],
|
||||
alpha: has_alpha.then(|| vec![((1u32 << bit_depth) - 1) as u16; n]),
|
||||
color: ColorInfo::BT709,
|
||||
profile_idc: Profile::P422_10.idc(),
|
||||
level_idc: 90,
|
||||
band_idc: 2,
|
||||
mastering_display: None,
|
||||
content_light: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chroma_width(&self) -> u32 {
|
||||
self.chroma.chroma_width(self.width)
|
||||
}
|
||||
|
||||
pub fn chroma_height(&self) -> u32 {
|
||||
self.chroma.chroma_height(self.height)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
//! RFC 9924 tables: zig-zag scan, 8x8 transform matrix, quantization level scales, and level limits.
|
||||
|
||||
/// 8x8 zig-zag scan order (`ScanOrder[sPos] = yC * 8 + xC`), derived from RFC 9924 §4.4.1 Figure 5.
|
||||
pub const ZIGZAG_8X8: [u8; 64] = [
|
||||
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
|
||||
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
|
||||
];
|
||||
|
||||
/// 8x8 transform matrix (`transMatrix` in RFC 9924 §6.3.2.3 Figure 25).
|
||||
pub const TRANS_MATRIX: [[i32; 8]; 8] = [
|
||||
[64, 64, 64, 64, 64, 64, 64, 64],
|
||||
[89, 75, 50, 18, -18, -50, -75, -89],
|
||||
[84, 35, -35, -84, -84, -35, 35, 84],
|
||||
[75, -18, -89, -50, 50, 89, 18, -75],
|
||||
[64, -64, -64, 64, 64, -64, -64, 64],
|
||||
[50, -89, 18, 75, -75, -18, 89, -50],
|
||||
[35, -84, 84, -35, -35, 84, -84, 35],
|
||||
[18, -50, 75, -89, 89, -75, 50, -18],
|
||||
];
|
||||
|
||||
/// Quantization scaling list `levelScale[k]` for `k = qP % 6` (RFC 9924 §6.3.1).
|
||||
pub const LEVEL_SCALE: [i32; 6] = [40, 45, 51, 57, 64, 71];
|
||||
|
||||
/// Default flat quantization matrix value when `use_q_matrix == 0` (RFC 9924 §5.3.7).
|
||||
pub const DEFAULT_Q_MATRIX_VAL: u8 = 16;
|
||||
|
||||
/// General level limits (`level_idc`, max luma sample rate, and max coded data rate in Mbit/s per band)
|
||||
/// from RFC 9924 §9.4.2 Table 4.
|
||||
pub const LEVEL_LIMITS: [(u8, u64, [u32; 4]); 14] = [
|
||||
(30, 3_041_280, [8, 11, 15, 23]),
|
||||
(33, 6_082_560, [16, 21, 30, 45]),
|
||||
(60, 15_667_200, [39, 54, 76, 114]),
|
||||
(63, 31_334_400, [78, 108, 152, 227]),
|
||||
(90, 66_846_720, [114, 159, 222, 333]),
|
||||
(93, 133_693_440, [227, 317, 444, 666]),
|
||||
(120, 265_420_800, [455, 637, 892, 1_338]),
|
||||
(123, 530_841_600, [910, 1_274, 1_784, 2_675]),
|
||||
(150, 1_061_683_200, [1_820, 2_548, 3_567, 5_350]),
|
||||
(153, 2_123_366_400, [3_639, 5_095, 7_133, 10_699]),
|
||||
(180, 4_777_574_400, [7_278, 10_189, 14_265, 21_397]),
|
||||
(183, 8_493_465_600, [14_556, 20_378, 28_529, 42_793]),
|
||||
(210, 16_986_931_200, [29_111, 40_756, 57_058, 85_586]),
|
||||
(213, 33_973_862_400, [58_222, 81_511, 114_115, 171_172]),
|
||||
];
|
||||
|
||||
/// Choose the lowest conforming `(level_idc, band_idc)` for a luma sample rate and bitrate (in kbps).
|
||||
pub fn select_level_and_band(luma_samples_per_sec: u64, kbps: u32) -> (u8, u8) {
|
||||
let mbps = kbps.div_ceil(1000);
|
||||
for &(level_idc, max_samples, bands) in &LEVEL_LIMITS {
|
||||
if luma_samples_per_sec <= max_samples {
|
||||
for (band_idc, &max_mbps) in bands.iter().enumerate() {
|
||||
if mbps <= max_mbps {
|
||||
return (level_idc, band_idc as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(213, 3)
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
//! Robustness: mutated, truncated, spliced, and random bitstreams must never panic (errors are fine).
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use filmcraft_apv::*;
|
||||
|
||||
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 below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn seeds() -> Vec<Vec<u8>> {
|
||||
let mut v = Vec::new();
|
||||
for (p, w, h) in
|
||||
[(Profile::P422_10, 64u32, 48u32), (Profile::P422_12, 70, 38), (Profile::P444_10, 48, 32), (Profile::P4444_10, 64, 32), (Profile::P400_10, 32, 32)]
|
||||
{
|
||||
let chroma = p.default_chroma();
|
||||
let depth = p.default_bit_depth();
|
||||
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||
for (i, s) in f.y.iter_mut().enumerate() {
|
||||
*s = (64 + (i * 37) % 800) as u16;
|
||||
}
|
||||
if let Some(a) = f.alpha.as_mut() {
|
||||
for (i, s) in a.iter_mut().enumerate() {
|
||||
*s = ((i * 19) % 1024) as u16;
|
||||
}
|
||||
}
|
||||
let mut cfg = EncoderConfig::new(p, w, h);
|
||||
cfg.qp = Some(16);
|
||||
let mut enc = Encoder::with_config(cfg).expect("seed encoder");
|
||||
v.push(enc.encode(&f).expect("seed encode"));
|
||||
v.push(enc.encode_raw_au(&f).expect("seed encode_raw_au"));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_streams_never_panic() {
|
||||
let seeds = seeds();
|
||||
for s in &seeds {
|
||||
decode_frame(s).expect("seed decodes");
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15);
|
||||
let mut decoded_ok = 0usize;
|
||||
let iters = 3000usize;
|
||||
|
||||
for i in 0..iters {
|
||||
let mut d = seeds[i % seeds.len()].clone();
|
||||
match rng.below(6) {
|
||||
0 => {
|
||||
// Bit flips
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let p = rng.below(d.len());
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Random byte overwrites
|
||||
for _ in 0..1 + rng.below(4) {
|
||||
let p = rng.below(d.len());
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Truncation
|
||||
let n = rng.below(d.len());
|
||||
d.truncate(n);
|
||||
}
|
||||
3 => {
|
||||
// Corrupt header fields
|
||||
if d.len() > 8 {
|
||||
let p = 4 + rng.below(24.min(d.len() - 4));
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
// Corrupt tile payload region
|
||||
let start = 24.min(d.len().saturating_sub(1));
|
||||
let off = start + rng.below(d.len().saturating_sub(start).max(1));
|
||||
for b in d.iter_mut().skip(off).take(1 + rng.below(48)) {
|
||||
*b = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Splice random bytes
|
||||
let p = rng.below(d.len());
|
||||
let extra: Vec<u8> = (0..1 + rng.below(16)).map(|_| rng.next() as u8).collect();
|
||||
d.splice(p..p, extra);
|
||||
}
|
||||
}
|
||||
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for depth in [None, Some(8), Some(16)] {
|
||||
let opts = DecodeOptions { bit_depth: depth, threads: i % 2 == 0 };
|
||||
if let Ok(f) = decode_frame_with(&d, &opts) {
|
||||
decoded_ok += 1;
|
||||
assert_eq!(f.y.len(), (f.width as usize) * (f.height as usize));
|
||||
}
|
||||
}
|
||||
let _ = probe(&d);
|
||||
let _ = split_raw_bitstream(&d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic on mutated stream iteration {i}");
|
||||
}
|
||||
println!("{decoded_ok} of {} mutated decodes succeeded", iters * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic() {
|
||||
let mut rng = Rng(123456789);
|
||||
for i in 0..2000 {
|
||||
let n = rng.below(400);
|
||||
let mut d: Vec<u8> = (0..n).map(|_| rng.next() as u8).collect();
|
||||
if d.len() >= 8 && rng.below(2) == 0 {
|
||||
d[..4].copy_from_slice(b"aPv1");
|
||||
} else if d.len() >= 12 && rng.below(2) == 0 {
|
||||
let au_sz = (n - 4) as u32;
|
||||
d[..4].copy_from_slice(&au_sz.to_be_bytes());
|
||||
d[4..8].copy_from_slice(b"aPv1");
|
||||
}
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
let _ = decode_frame(&d);
|
||||
let _ = probe(&d);
|
||||
let _ = split_raw_bitstream(&d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic on random bytes iteration {i}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
//! Conformance, bit-exactness, and quality tests against ffmpeg's APV decoder (external oracle).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use filmcraft_apv::{
|
||||
ChromaFormat, ContentLightLevel, DecodeOptions, Encoder, EncoderConfig, Frame, MasteringDisplay, Profile, decode_frame, decode_frame_with, probe,
|
||||
};
|
||||
|
||||
fn ffmpeg() -> Option<PathBuf> {
|
||||
// The APV demuxer is new in ffmpeg 8.0; older toolchains are found but
|
||||
// cannot read the fixtures, so they skip like a missing tool (#1249).
|
||||
filmcraft_testkit::ffmpeg_with_format_or_skip("apv oracle", "apv", "8.0")
|
||||
}
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
filmcraft_testkit::fixtures_dir("apv")
|
||||
}
|
||||
|
||||
fn native_pix_fmt(profile: Profile) -> &'static str {
|
||||
match profile {
|
||||
Profile::P422_10 => "yuv422p10le",
|
||||
Profile::P422_12 => "yuv422p12le",
|
||||
Profile::P444_10 => "yuv444p10le",
|
||||
Profile::P444_12 => "yuv444p12le",
|
||||
Profile::P4444_10 => "yuva444p10le",
|
||||
Profile::P4444_12 => "yuva444p12le",
|
||||
Profile::P400_10 => "gray10le",
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a raw `.apv` file with ffmpeg (`-v error -xerror`) to planar `u16` samples.
|
||||
fn ffmpeg_decode_raw(ff: &Path, apv_path: &Path, pix_fmt: &str) -> (Vec<u16>, String) {
|
||||
let out = Command::new(ff)
|
||||
.args(["-hide_banner", "-v", "error", "-xerror", "-f", "apv", "-i"])
|
||||
.arg(apv_path)
|
||||
.args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
|
||||
.output()
|
||||
.expect("run ffmpeg");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
assert!(out.status.success(), "ffmpeg failed on {:?}: {}", apv_path, stderr);
|
||||
let samples = out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
|
||||
(samples, stderr)
|
||||
}
|
||||
|
||||
fn synthetic_frame(profile: Profile, w: u32, h: u32) -> Frame {
|
||||
let chroma = profile.default_chroma();
|
||||
let depth = profile.default_bit_depth();
|
||||
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||
let max_v = (1u32 << depth) - 1;
|
||||
let lo = 16u32 << (depth - 8);
|
||||
let hi = 235u32 << (depth - 8);
|
||||
let span = hi - lo;
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Mix smooth gradients, diagonal edges, and high-frequency patterns
|
||||
let grad = (x * 7 + y * 13) % span;
|
||||
let checker = if ((x / 3) ^ (y / 5)) & 1 == 0 { 40 << (depth - 10) } else { 0 };
|
||||
f.y[(y * w + x) as usize] = (lo + (grad + checker) % span).min(max_v) as u16;
|
||||
}
|
||||
}
|
||||
let cw = f.chroma_width();
|
||||
let ch = f.chroma_height();
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
let u = lo + ((x * 11 + y * 5 + 100) % span);
|
||||
let v = lo + ((x * 3 + y * 17 + 300) % span);
|
||||
f.cb[(y * cw + x) as usize] = u.min(max_v) as u16;
|
||||
f.cr[(y * cw + x) as usize] = v.min(max_v) as u16;
|
||||
}
|
||||
}
|
||||
if let Some(a) = f.alpha.as_mut() {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let dx = x as i32 - (w as i32) / 2;
|
||||
let dy = y as i32 - (h as i32) / 2;
|
||||
a[(y * w + x) as usize] =
|
||||
if dx * dx + dy * dy < ((w.min(h) as i32) / 3).pow(2) { max_v as u16 } else { ((x * 19 + y * 23) % (max_v + 1)) as u16 };
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn flatten_frame(f: &Frame) -> Vec<u16> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&f.y);
|
||||
v.extend_from_slice(&f.cb);
|
||||
v.extend_from_slice(&f.cr);
|
||||
if let Some(a) = &f.alpha {
|
||||
v.extend_from_slice(a);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn psnr(a: &[u16], b: &[u16], peak: f64) -> f64 {
|
||||
if a.is_empty() {
|
||||
return 99.0;
|
||||
}
|
||||
let mse: f64 = a.iter().zip(b).map(|(&x, &y)| (x as f64 - y as f64).powi(2)).sum::<f64>() / a.len() as f64;
|
||||
if mse == 0.0 { 99.0 } else { 10.0 * (peak * peak / mse).log10() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_profiles_bit_exact_with_ffmpeg() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let dir = fixture_dir();
|
||||
|
||||
for profile in Profile::ALL {
|
||||
let (w, h) = (320u32, 192u32);
|
||||
let src = synthetic_frame(profile, w, h);
|
||||
let mut cfg = EncoderConfig::new(profile, w, h);
|
||||
cfg.qp = Some(12);
|
||||
let mut enc = Encoder::with_config(cfg).expect("encoder init");
|
||||
|
||||
let raw_au = enc.encode_raw_au(&src).expect("encode_raw_au");
|
||||
let apv_path = dir.join(format!("oracle_{}.apv", profile.short_name()));
|
||||
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||
|
||||
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||
assert!(stderr.trim().is_empty(), "{profile:?}: ffmpeg stderr: {stderr}");
|
||||
|
||||
let ours = decode_frame(&raw_au).expect("our decode");
|
||||
let our_samples = flatten_frame(&ours);
|
||||
assert_eq!(our_samples.len(), ff_samples.len(), "{profile:?}: sample count mismatch");
|
||||
|
||||
let max_diff = our_samples.iter().zip(&ff_samples).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).max().unwrap_or(0);
|
||||
assert_eq!(max_diff, 0, "{profile:?}: our decoder must be 100% bit-exact with ffmpeg (max_diff={max_diff})");
|
||||
|
||||
let peak = ((1u32 << profile.default_bit_depth()) - 1) as f64;
|
||||
let y_psnr = psnr(&ours.y, &src.y, peak);
|
||||
assert!(y_psnr > 45.0, "{profile:?}: expected high PSNR at qp=12, got {y_psnr:.2} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_dimensions_and_multi_tile_bit_exact_with_ffmpeg() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let dir = fixture_dir();
|
||||
|
||||
for (profile, w, h) in [
|
||||
(Profile::P422_10, 640u32, 360u32),
|
||||
(Profile::P422_10, 334, 202),
|
||||
(Profile::P444_10, 333, 201),
|
||||
(Profile::P4444_12, 512, 288),
|
||||
(Profile::P400_10, 70, 38),
|
||||
] {
|
||||
let mut src = synthetic_frame(profile, w, h);
|
||||
src.mastering_display = Some(MasteringDisplay {
|
||||
primaries: [(46400, 19136), (11141, 52232), (8585, 3015)],
|
||||
white_point: (20493, 21561),
|
||||
max_luminance: 1000 << 8,
|
||||
min_luminance: 1,
|
||||
});
|
||||
src.content_light = Some(ContentLightLevel { max_cll: 1000, max_fall: 400 });
|
||||
|
||||
let mut enc = Encoder::new(profile, w, h).expect("encoder init");
|
||||
let raw_au = enc.encode_raw_au(&src).expect("encode");
|
||||
let apv_path = dir.join(format!("oracle_geom_{}_{w}x{h}.apv", profile.short_name()));
|
||||
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||
|
||||
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||
assert!(stderr.trim().is_empty(), "{profile:?} {w}x{h}: {stderr}");
|
||||
|
||||
let hdr = probe(&raw_au).expect("probe");
|
||||
assert_eq!((hdr.width, hdr.height), (w, h));
|
||||
|
||||
let ours = decode_frame(&raw_au).expect("decode");
|
||||
assert_eq!(ours.mastering_display, src.mastering_display);
|
||||
assert_eq!(ours.content_light, src.content_light);
|
||||
|
||||
let our_samples = flatten_frame(&ours);
|
||||
assert_eq!(our_samples, ff_samples, "{profile:?} {w}x{h}: bit-exact match with ffmpeg failed");
|
||||
|
||||
// Also verify single-threaded decode produces identical output
|
||||
let ours_st = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: None, threads: false }).expect("decode single-threaded");
|
||||
assert_eq!(ours, ours_st);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_q_matrix_and_fh_tile_sizes_bit_exact_with_ffmpeg() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let dir = fixture_dir();
|
||||
|
||||
let profile = Profile::P444_12;
|
||||
let (w, h) = (512u32, 256u32);
|
||||
let src = synthetic_frame(profile, w, h);
|
||||
|
||||
let mut q_matrix = [[16u8; 64]; 4];
|
||||
for (c, qm) in q_matrix.iter_mut().enumerate() {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
// Intentionally asymmetric (x != y weight) to verify exact (x, y) matrix orientation
|
||||
qm[y * 8 + x] = (10 + c * 3 + y * 5 + x * 2) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut cfg = EncoderConfig::new(profile, w, h);
|
||||
cfg.qp = Some(18);
|
||||
cfg.q_matrix = Some(q_matrix);
|
||||
cfg.write_tile_sizes_in_fh = true;
|
||||
cfg.tile_width_in_mbs = 16;
|
||||
cfg.tile_height_in_mbs = 8;
|
||||
|
||||
let mut enc = Encoder::with_config(cfg).expect("encoder init");
|
||||
let raw_au = enc.encode_raw_au(&src).expect("encode");
|
||||
let apv_path = dir.join("oracle_custom_qmat_444-12.apv");
|
||||
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||
|
||||
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||
assert!(stderr.trim().is_empty(), "ffmpeg stderr: {stderr}");
|
||||
|
||||
let hdr = probe(&raw_au).expect("probe");
|
||||
assert!(hdr.use_q_matrix);
|
||||
assert!(hdr.tile_sizes_in_fh.is_some());
|
||||
assert_eq!(hdr.num_tiles(), 4);
|
||||
|
||||
let ours = decode_frame(&raw_au).expect("decode");
|
||||
let our_samples = flatten_frame(&ours);
|
||||
assert_eq!(our_samples, ff_samples, "asymmetric q_matrix decode must be bit-exact with ffmpeg");
|
||||
|
||||
// Verify 8-bit and 16-bit rescaled output
|
||||
let d8 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(8), threads: true }).expect("decode 8-bit");
|
||||
assert_eq!(d8.bit_depth, 8);
|
||||
assert!(d8.y.iter().all(|&s| s <= 255));
|
||||
|
||||
let d16 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(16), threads: true }).expect("decode 16-bit");
|
||||
assert_eq!(d16.bit_depth, 16);
|
||||
}
|
||||
|
|
@ -132,10 +132,6 @@ impl Biquad {
|
|||
pub fn reset(&mut self) {
|
||||
*self = Biquad::new();
|
||||
}
|
||||
/// Exact zero state, including the sign of zero, for identity-filter fast paths.
|
||||
pub(crate) fn is_clear(&self) -> bool {
|
||||
self.s1.to_bits() == 0 && self.s2.to_bits() == 0
|
||||
}
|
||||
/// Filter a buffer in place.
|
||||
pub fn process(&mut self, c: &Coeffs, buf: &mut [f32]) {
|
||||
for v in buf {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//!
|
||||
//! **Channel order.** 5.1 audio is always in the SMPTE / WAVE order **L, R, C, LFE, Ls, Rs**
|
||||
//! ([`L`], [`R`], [`C`], [`LFE`], [`LS`], [`RS`]); stereo is L, R; mono is one channel. Codecs with a
|
||||
//! different native order (AAC: C, L, R, Ls, Rs, LFE) reorder at the codec boundary.
|
||||
//! different native order (AAC: C, L, R, Ls, Rs, LFE) reorder at the encoder boundary.
|
||||
//!
|
||||
//! **Downmix** follows ITU-R BS.775 (Table 2, "3/2 source" rows), with `k = 1/√2` (−3 dB):
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -110,57 +110,6 @@ impl BandBank {
|
|||
pub(crate) fn process(&mut self, channels: &mut [&mut [f32]], out_gain: &mut Smoothed) {
|
||||
let n = block_len(channels);
|
||||
let nch = channels.len().min(self.state.len());
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
if !out_gain.is_smoothing()
|
||||
&& self.bands.iter().all(|b| !b.dirty && !b.freq.is_smoothing() && !b.gain.is_smoothing() && !b.q.is_smoothing() && !b.mix.is_smoothing())
|
||||
{
|
||||
// Keep the exact per-sample arithmetic and recursive state; only the settled
|
||||
// parameter/smoother bookkeeping is hoisted out of the hot loop.
|
||||
let g = out_gain.value() as f64;
|
||||
for (channel, state) in channels.iter_mut().take(nch).zip(&mut self.state) {
|
||||
for (b, st) in self.bands.iter().zip(state.iter_mut()) {
|
||||
if b.mix.value() <= 0.0 {
|
||||
st.reset();
|
||||
}
|
||||
}
|
||||
// A settled unit transfer with zero state is exactly transparent for finite
|
||||
// f32 input. Do not discard a tail or bypass a non-finite sample. Bounded
|
||||
// coefficients also ensure intermediate f64 products cannot overflow.
|
||||
let transparent = self.bands.iter().zip(state.iter()).all(|(b, st)| {
|
||||
b.mix.value() <= 0.0
|
||||
|| (st.is_clear()
|
||||
&& b.coeffs.b0 == 1.0
|
||||
&& b.coeffs.b1 == b.coeffs.a1
|
||||
&& b.coeffs.b2 == b.coeffs.a2
|
||||
&& b.coeffs.b1.abs() <= 4.0
|
||||
&& b.coeffs.b2.abs() <= 4.0)
|
||||
});
|
||||
if transparent && channel.iter().take(n).all(|x| x.is_finite()) {
|
||||
let active = self.bands.iter().any(|b| b.mix.value() > 0.0);
|
||||
for sample in channel.iter_mut().take(n) {
|
||||
let x = *sample as f64;
|
||||
// An active identity biquad adds +0, which normalizes negative zero.
|
||||
*sample = ((if active { x + 0.0 } else { x }) * g) as f32;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for sample in channel.iter_mut().take(n) {
|
||||
let mut x = *sample as f64;
|
||||
for (b, st) in self.bands.iter().zip(state.iter_mut()) {
|
||||
let m = b.mix.value() as f64;
|
||||
if m > 0.0 {
|
||||
let y = st.tick(&b.coeffs, x);
|
||||
x += m * (y - x);
|
||||
}
|
||||
}
|
||||
*sample = (x * g) as f32;
|
||||
}
|
||||
}
|
||||
self.counter = (self.counter + (n % COEFF_INTERVAL as usize) as u32) % COEFF_INTERVAL;
|
||||
return;
|
||||
}
|
||||
for i in 0..n {
|
||||
if self.counter == 0 {
|
||||
self.update_coeffs(false);
|
||||
|
|
@ -427,119 +376,3 @@ mod tests {
|
|||
assert!((x[4799] - 0.5 * crate::db_to_gain(12.0)).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl BandBank {
|
||||
fn process_reference(&mut self, channels: &mut [&mut [f32]], out_gain: &mut Smoothed) {
|
||||
let n = block_len(channels);
|
||||
let nch = channels.len().min(self.state.len());
|
||||
for i in 0..n {
|
||||
if self.counter == 0 {
|
||||
self.update_coeffs(false);
|
||||
}
|
||||
self.counter = (self.counter + 1) % COEFF_INTERVAL;
|
||||
for b in &mut self.bands {
|
||||
b.freq.tick();
|
||||
b.gain.tick();
|
||||
b.q.tick();
|
||||
if b.freq.is_smoothing() || b.gain.is_smoothing() || b.q.is_smoothing() {
|
||||
b.dirty = true;
|
||||
}
|
||||
}
|
||||
let g = out_gain.tick();
|
||||
// Tick the band mixes once per sample (shared by all channels).
|
||||
let mut mixes = [0.0f32; MAX_BANDS];
|
||||
for (m, b) in mixes.iter_mut().zip(self.bands.iter_mut()) {
|
||||
*m = b.mix.tick();
|
||||
}
|
||||
for ch in 0..nch {
|
||||
let mut x = channels[ch][i] as f64;
|
||||
for (k, b) in self.bands.iter().enumerate() {
|
||||
let m = mixes[k] as f64;
|
||||
let st = &mut self.state[ch][k];
|
||||
if m > 0.0 {
|
||||
let y = st.tick(&b.coeffs, x);
|
||||
x += m * (y - x);
|
||||
} else {
|
||||
st.reset();
|
||||
}
|
||||
}
|
||||
channels[ch][i] = (x * g as f64) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod throughput_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn flat_eq_fast_path_preserves_samples_and_filter_tails() {
|
||||
let mut fast = BandBank::new(48000.0, 2, 1);
|
||||
fast.set_band(0, true, FilterType::Peaking, 1000.0, 0.0, 0.7, true);
|
||||
fast.reset();
|
||||
let mut reference = fast.clone();
|
||||
let mut gain = Smoothed::new(1.0, 0);
|
||||
let mut ref_gain = gain;
|
||||
for input in
|
||||
[vec![-0.0, 0.0, f32::MIN_POSITIVE, f32::from_bits(1), f32::MAX, -f32::MAX, 0.7], vec![0.0; 100], vec![f32::INFINITY, f32::NEG_INFINITY, f32::NAN]]
|
||||
{
|
||||
let mut a = [input.clone(), input];
|
||||
let mut b = a.clone();
|
||||
let [al, ar] = &mut a;
|
||||
let [bl, br] = &mut b;
|
||||
fast.process(&mut [al, ar], &mut gain);
|
||||
reference.process_reference(&mut [bl, br], &mut ref_gain);
|
||||
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
|
||||
assert_eq!(a.to_bits(), b.to_bits());
|
||||
}
|
||||
}
|
||||
// A newly flat filter must drain its old state, not drop the tail.
|
||||
fast.reset();
|
||||
fast.set_band(0, true, FilterType::Peaking, 1000.0, 12.0, 0.7, true);
|
||||
fast.update_coeffs(true);
|
||||
fast.process(&mut [&mut [0.5], &mut [0.5]], &mut gain);
|
||||
fast.set_band(0, true, FilterType::Peaking, 1000.0, 0.0, 0.7, true);
|
||||
fast.update_coeffs(true);
|
||||
reference = fast.clone();
|
||||
let mut a = [vec![0.0; 4096], vec![0.0; 4096]];
|
||||
let mut b = a.clone();
|
||||
let [al, ar] = &mut a;
|
||||
let [bl, br] = &mut b;
|
||||
fast.process(&mut [al, ar], &mut gain);
|
||||
reference.process_reference(&mut [bl, br], &mut ref_gain);
|
||||
assert_eq!(a, b);
|
||||
assert!(a[0].iter().any(|x| *x != 0.0));
|
||||
}
|
||||
#[test]
|
||||
fn settled_and_automated_eq_matches_original_loop() {
|
||||
let mut fast = BandBank::new(48000.0, 2, 3);
|
||||
fast.reset();
|
||||
let mut reference = fast.clone();
|
||||
let mut gain = Smoothed::with_ms(1.0, 48000.0, 20.0);
|
||||
let mut ref_gain = gain;
|
||||
for block in 0..500 {
|
||||
if matches!(block, 2 | 90 | 180 | 270 | 360) {
|
||||
let on = block != 180;
|
||||
let db = if block == 270 { 0.0 } else { 9.0 };
|
||||
for bank in [&mut fast, &mut reference] {
|
||||
bank.set_band(0, on, FilterType::Peaking, 1733.0, db, 0.7, false);
|
||||
}
|
||||
gain.set(if block == 360 { 1.0 } else { 0.8 });
|
||||
ref_gain.set(gain.target());
|
||||
}
|
||||
let n = [0, 1, 17, 64, 513][block % 5];
|
||||
let input: Vec<f32> = (0..n).map(|i| if block > 400 { 0.0 } else { ((i + block * 13) as f32 * 0.17).sin() * 0.3 }).collect();
|
||||
let mut a = [input.clone(), input.clone()];
|
||||
let mut b = a.clone();
|
||||
let [al, ar] = &mut a;
|
||||
let [bl, br] = &mut b;
|
||||
fast.process(&mut [al, ar], &mut gain);
|
||||
reference.process_reference(&mut [bl, br], &mut ref_gain);
|
||||
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
|
||||
assert_eq!(a.to_bits(), b.to_bits(), "block {block}");
|
||||
}
|
||||
assert_eq!(fast.counter, reference.counter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,26 +5,12 @@ use crate::{AudioEffect, ParamSpec, ParamValues, Smoothed, Unit, block_len, flus
|
|||
/// Fractional read from a circular buffer: `delay` samples behind write position `w`.
|
||||
#[inline]
|
||||
fn read_frac(buf: &[f32], w: usize, delay: f32) -> f32 {
|
||||
read_prepared(buf, w, prepare_delay(buf.len(), delay))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn prepare_delay(len: usize, delay: f32) -> (usize, f32) {
|
||||
let len = buf.len();
|
||||
let d = delay.clamp(1.0, (len - 2) as f32);
|
||||
let di = d.floor();
|
||||
let frac = d - di;
|
||||
// The delay is bounded by this ring's length, so wrapping needs no integer division.
|
||||
// Keep the fallback for very large rings whose f32 length rounds upward.
|
||||
let distance = di as usize;
|
||||
let distance = if distance <= len { distance } else { distance % len };
|
||||
(distance, frac)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_prepared(buf: &[f32], w: usize, (distance, frac): (usize, f32)) -> f32 {
|
||||
let len = buf.len();
|
||||
let i0 = if w >= distance { w - distance } else { len - (distance - w) };
|
||||
let i1 = if i0 == 0 { len - 1 } else { i0 - 1 };
|
||||
let i0 = (w + len - di as usize) % len;
|
||||
let i1 = (i0 + len - 1) % len;
|
||||
buf[i0] + (buf[i1] - buf[i0]) * frac
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +103,7 @@ impl AudioEffect for Delay {
|
|||
buf[self.w] = flush32(x + fb * yd);
|
||||
channels[ch][i] = x + mix * (yd - x);
|
||||
}
|
||||
self.w = if self.w + 1 == len { 0 } else { self.w + 1 };
|
||||
self.w = (self.w + 1) % len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -146,7 +132,6 @@ pub struct Reverb {
|
|||
len: [Smoothed; FDN],
|
||||
lp: [f32; FDN],
|
||||
gains: [f32; FDN],
|
||||
gain_key: ([f32; FDN], f32),
|
||||
damp: Smoothed,
|
||||
rt60: Smoothed,
|
||||
mix: Smoothed,
|
||||
|
|
@ -177,7 +162,6 @@ impl Reverb {
|
|||
len: [sm(1.0); FDN],
|
||||
lp: [0.0; FDN],
|
||||
gains: [0.0; FDN],
|
||||
gain_key: ([0.0; FDN], 0.0),
|
||||
damp: sm(0.0),
|
||||
rt60: sm(1.0),
|
||||
mix: sm(0.0),
|
||||
|
|
@ -211,11 +195,6 @@ impl Reverb {
|
|||
|
||||
fn update_gains(&mut self) {
|
||||
let rt = self.rt60.value().max(0.01);
|
||||
let lengths = self.len.map(|l| l.value());
|
||||
if self.gain_key == (lengths, rt) {
|
||||
return;
|
||||
}
|
||||
self.gain_key = (lengths, rt);
|
||||
for k in 0..FDN {
|
||||
// Attenuation per pass so the loop decays 60 dB in rt seconds.
|
||||
self.gains[k] = 10f32.powf(-3.0 * self.len[k].value() / (rt * self.sr));
|
||||
|
|
@ -243,20 +222,13 @@ impl AudioEffect for Reverb {
|
|||
let line_len = self.lines[0].len();
|
||||
let pre_len = self.pre.len();
|
||||
let hh = 2.0 / FDN as f32;
|
||||
// During a settled block these positions cannot change. Preserve the original path
|
||||
// while smoothing, and preserve the 32-sample gain update grid in both cases.
|
||||
let fixed = self.len.iter().all(|l| !l.is_smoothing());
|
||||
let delays = self.len.map(|l| prepare_delay(line_len, l.value()));
|
||||
let predelay = (!self.pre_len.is_smoothing()).then(|| prepare_delay(pre_len, self.pre_len.value()));
|
||||
for i in 0..n {
|
||||
if self.counter == 0 {
|
||||
self.update_gains();
|
||||
}
|
||||
self.counter = (self.counter + 1) % 32;
|
||||
if !fixed {
|
||||
for l in &mut self.len {
|
||||
l.tick();
|
||||
}
|
||||
for l in &mut self.len {
|
||||
l.tick();
|
||||
}
|
||||
self.rt60.tick();
|
||||
let damp = self.damp.tick();
|
||||
|
|
@ -269,16 +241,13 @@ impl AudioEffect for Reverb {
|
|||
}
|
||||
mono /= nch as f32;
|
||||
self.pre[self.pre_w] = mono;
|
||||
let input = match predelay {
|
||||
Some(delay) => read_prepared(&self.pre, self.pre_w, delay),
|
||||
None => read_frac(&self.pre, self.pre_w, pd),
|
||||
};
|
||||
self.pre_w = if self.pre_w + 1 == pre_len { 0 } else { self.pre_w + 1 };
|
||||
let input = read_frac(&self.pre, self.pre_w, pd);
|
||||
self.pre_w = (self.pre_w + 1) % pre_len;
|
||||
|
||||
let mut v = [0.0f32; FDN];
|
||||
let mut sum = 0.0;
|
||||
for k in 0..FDN {
|
||||
let d = if fixed { read_prepared(&self.lines[k], self.w, delays[k]) } else { read_frac(&self.lines[k], self.w, self.len[k].value()) };
|
||||
let d = read_frac(&self.lines[k], self.w, self.len[k].value());
|
||||
self.lp[k] = flush32(d + (self.lp[k] - d) * damp);
|
||||
v[k] = self.lp[k] * self.gains[k];
|
||||
sum += v[k];
|
||||
|
|
@ -290,7 +259,7 @@ impl AudioEffect for Reverb {
|
|||
wl += SIGN_L[k] * v[k];
|
||||
wr += SIGN_R[k] * v[k];
|
||||
}
|
||||
self.w = if self.w + 1 == line_len { 0 } else { self.w + 1 };
|
||||
self.w = (self.w + 1) % line_len;
|
||||
let (wl, wr) = (wl * 0.35, wr * 0.35);
|
||||
for (c, ch) in channels.iter_mut().enumerate() {
|
||||
let x = ch[i];
|
||||
|
|
@ -401,129 +370,3 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_frac_reference(buf: &[f32], w: usize, delay: f32) -> f32 {
|
||||
let len = buf.len();
|
||||
let d = delay.clamp(1.0, (len - 2) as f32);
|
||||
let di = d.floor();
|
||||
let frac = d - di;
|
||||
let i0 = (w + len - di as usize) % len;
|
||||
let i1 = (i0 + len - 1) % len;
|
||||
buf[i0] + (buf[i1] - buf[i0]) * frac
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl Reverb {
|
||||
fn update_gains_reference(&mut self) {
|
||||
let rt = self.rt60.value().max(0.01);
|
||||
for k in 0..FDN {
|
||||
// Attenuation per pass so the loop decays 60 dB in rt seconds.
|
||||
self.gains[k] = 10f32.powf(-3.0 * self.len[k].value() / (rt * self.sr));
|
||||
}
|
||||
}
|
||||
fn process_reference(&mut self, channels: &mut [&mut [f32]]) {
|
||||
let n = block_len(channels);
|
||||
let nch = channels.len();
|
||||
if nch == 0 {
|
||||
return;
|
||||
}
|
||||
let line_len = self.lines[0].len();
|
||||
let pre_len = self.pre.len();
|
||||
let hh = 2.0 / FDN as f32;
|
||||
for i in 0..n {
|
||||
if self.counter == 0 {
|
||||
self.update_gains_reference();
|
||||
}
|
||||
self.counter = (self.counter + 1) % 32;
|
||||
for l in &mut self.len {
|
||||
l.tick();
|
||||
}
|
||||
self.rt60.tick();
|
||||
let damp = self.damp.tick();
|
||||
let mix = self.mix.tick();
|
||||
let pd = self.pre_len.tick();
|
||||
|
||||
let mut mono = 0.0;
|
||||
for ch in channels.iter() {
|
||||
mono += ch[i];
|
||||
}
|
||||
mono /= nch as f32;
|
||||
self.pre[self.pre_w] = mono;
|
||||
let input = read_frac_reference(&self.pre, self.pre_w, pd);
|
||||
self.pre_w = (self.pre_w + 1) % pre_len;
|
||||
|
||||
let mut v = [0.0f32; FDN];
|
||||
let mut sum = 0.0;
|
||||
for k in 0..FDN {
|
||||
let d = read_frac_reference(&self.lines[k], self.w, self.len[k].value());
|
||||
self.lp[k] = flush32(d + (self.lp[k] - d) * damp);
|
||||
v[k] = self.lp[k] * self.gains[k];
|
||||
sum += v[k];
|
||||
}
|
||||
let (mut wl, mut wr) = (0.0, 0.0);
|
||||
for k in 0..FDN {
|
||||
let fbk = v[k] - hh * sum;
|
||||
self.lines[k][self.w] = flush32(fbk + input * 0.5);
|
||||
wl += SIGN_L[k] * v[k];
|
||||
wr += SIGN_R[k] * v[k];
|
||||
}
|
||||
self.w = (self.w + 1) % line_len;
|
||||
let (wl, wr) = (wl * 0.35, wr * 0.35);
|
||||
for (c, ch) in channels.iter_mut().enumerate() {
|
||||
let x = ch[i];
|
||||
let wet = if nch == 1 {
|
||||
0.5 * (wl + wr)
|
||||
} else if c % 2 == 0 {
|
||||
wl
|
||||
} else {
|
||||
wr
|
||||
};
|
||||
ch[i] = x + mix * (wet - x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod throughput_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn circular_reads_match_modulo_reference() {
|
||||
for len in [3, 4, 17, 257, 14078] {
|
||||
let buf: Vec<f32> = (0..len).map(|i| (i as f32 * 0.17).sin()).collect();
|
||||
for w in 0..len {
|
||||
for delay in [-10.0, 0.0, 1.0, 1.25, 2.75, len as f32 - 2.0, len as f32, f32::INFINITY] {
|
||||
assert_eq!(read_frac(&buf, w, delay).to_bits(), read_frac_reference(&buf, w, delay).to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn reverb_automation_and_tail_match_original_loop() {
|
||||
let mut fast = Reverb::new(48000.0, 2);
|
||||
let mut reference = Reverb::new(48000.0, 2);
|
||||
for block in 0..800 {
|
||||
if matches!(block, 1 | 80 | 200 | 320 | 440) {
|
||||
for fx in [&mut fast, &mut reference] {
|
||||
fx.set_param("size", (block % 101) as f32);
|
||||
fx.set_param("decay", 0.1 + (block % 20) as f32);
|
||||
fx.set_param("predelay", (block % 200) as f32);
|
||||
}
|
||||
}
|
||||
if block == 600 {
|
||||
fast.reset();
|
||||
reference.reset();
|
||||
}
|
||||
let n = [0, 1, 17, 64, 513][block % 5];
|
||||
let input: Vec<f32> = (0..n).map(|i| if block > 450 { 0.0 } else { ((i + block * 13) as f32 * 0.17).sin() * 0.3 }).collect();
|
||||
let mut a = [input.clone(), input];
|
||||
let mut b = a.clone();
|
||||
let [al, ar] = &mut a;
|
||||
let [bl, br] = &mut b;
|
||||
fast.process(&mut [al, ar]);
|
||||
reference.process_reference(&mut [bl, br]);
|
||||
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
|
||||
assert_eq!(a.to_bits(), b.to_bits(), "block {block}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
//! engine and UI can build controls generically.
|
||||
//! * [`biquad`] — RBJ-cookbook biquads (TDF-II) with analytic magnitude response.
|
||||
//! * [`sync`] — offset between two recordings of one event (GCC-PHAT, sample-accurate).
|
||||
//! * [`resample`] — streaming, block-invariant rate conversion for playback on a device at another rate.
|
||||
//! * [`channels`] — channel layouts, ITU-R BS.775 up/downmix, 5.1 mixdown types, the 5.1 panner.
|
||||
//!
|
||||
//! Conventions: audio is **planar f32** (`&mut [&mut [f32]]`, one slice per channel, all the same
|
||||
|
|
@ -28,7 +27,6 @@ pub mod fft;
|
|||
pub mod loudness;
|
||||
pub mod oversample;
|
||||
pub mod remix;
|
||||
pub mod resample;
|
||||
mod smooth;
|
||||
pub mod sync;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
//! Streaming sample-rate conversion for playback, when the output device runs at another rate
|
||||
//! than the sequence (a 44.1 kHz device playing a 48 kHz sequence).
|
||||
//!
|
||||
//! Every output sample's source position comes from its absolute output frame, as an exact
|
||||
//! rational (`frame × from / to`), and is linearly interpolated, so the output is identical however
|
||||
//! the stream is cut into blocks: no phase jump at block boundaries. Source frames are pulled in
|
||||
//! contiguous, non-overlapping spans, so whatever produces them (the mixer, with its effect state)
|
||||
//! sees one continuous stream. Output lags the source by one source sample (≈21 µs at 48 kHz),
|
||||
//! which is what lets each block interpolate without reading ahead.
|
||||
|
||||
/// Converts a planar stream from `from` Hz to `to` Hz, block by block.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamResampler {
|
||||
from: u32,
|
||||
to: u32,
|
||||
/// Source frames `[held_start, held_start + held[c].len())` kept from the last pull.
|
||||
held_start: i64,
|
||||
held: Vec<Vec<f32>>,
|
||||
/// The output frame the next block is expected to start at (contiguous playback).
|
||||
next_out: Option<i64>,
|
||||
}
|
||||
|
||||
impl StreamResampler {
|
||||
/// Rates of zero are treated as 1 Hz rather than dividing by zero.
|
||||
pub fn new(from: u32, to: u32) -> Self {
|
||||
Self { from: from.max(1), to: to.max(1), held_start: 0, held: Vec::new(), next_out: None }
|
||||
}
|
||||
|
||||
pub fn rates(&self) -> (u32, u32) {
|
||||
(self.from, self.to)
|
||||
}
|
||||
|
||||
/// The source frame whose sample lands at (or just before) output frame `out`.
|
||||
fn source_index(&self, out: i64) -> (i64, f32) {
|
||||
let p = out as i128 * self.from as i128;
|
||||
let to = self.to as i128;
|
||||
(p.div_euclid(to) as i64, (p.rem_euclid(to) as f64 / to as f64) as f32)
|
||||
}
|
||||
|
||||
/// Output frames `[out, out + n)`, planar. `pull(start, frames)` returns source frames
|
||||
/// `[start, start + frames)`, planar (missing samples read as silence). A block that does not
|
||||
/// continue the previous one (a seek) starts the stream afresh.
|
||||
pub fn process(&mut self, out: i64, n: usize, mut pull: impl FnMut(i64, usize) -> Vec<Vec<f32>>) -> Vec<Vec<f32>> {
|
||||
if n == 0 {
|
||||
return vec![Vec::new(); self.held.len().max(1)];
|
||||
}
|
||||
let (first, _) = self.source_index(out);
|
||||
let (last, _) = self.source_index(out.saturating_add(n as i64 - 1));
|
||||
// interpolation reads source frames first - 1 ..= last
|
||||
if self.next_out != Some(out) || self.held.is_empty() {
|
||||
self.held_start = first - 1;
|
||||
self.held.clear();
|
||||
}
|
||||
let held_end = self.held_start + self.held.first().map_or(0, Vec::len) as i64;
|
||||
if last + 1 > held_end {
|
||||
let want = (last + 1 - held_end) as usize;
|
||||
let got = pull(held_end, want);
|
||||
if self.held.is_empty() {
|
||||
self.held = vec![Vec::new(); got.len().max(1)];
|
||||
}
|
||||
for (c, h) in self.held.iter_mut().enumerate() {
|
||||
let src = got.get(c).map(Vec::as_slice).unwrap_or_default();
|
||||
h.extend((0..want).map(|i| src.get(i).copied().unwrap_or(0.0)));
|
||||
}
|
||||
}
|
||||
let mut dst = vec![vec![0.0f32; n]; self.held.len()];
|
||||
for (i, frame) in (out..).take(n).enumerate() {
|
||||
let (s, frac) = self.source_index(frame);
|
||||
// held starts at first - 1, so the earlier frame is always held
|
||||
let k = usize::try_from(s - self.held_start).unwrap_or(0);
|
||||
for (d, h) in dst.iter_mut().zip(&self.held) {
|
||||
let a = k.checked_sub(1).and_then(|j| h.get(j)).copied().unwrap_or(0.0);
|
||||
let b = h.get(k).copied().unwrap_or(0.0);
|
||||
if let Some(x) = d.get_mut(i) {
|
||||
*x = a + (b - a) * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
// keep what the next block can still read: from its first frame - 1 on
|
||||
let (next_first, _) = self.source_index(out.saturating_add(n as i64));
|
||||
let drop = (next_first - 1 - self.held_start).clamp(0, self.held.first().map_or(0, Vec::len) as i64) as usize;
|
||||
for h in &mut self.held {
|
||||
h.drain(..drop);
|
||||
}
|
||||
self.held_start += drop as i64;
|
||||
self.next_out = Some(out.saturating_add(n as i64));
|
||||
dst
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A sine at `hz`, sampled at `rate`, from absolute frame `start`; logs every pull.
|
||||
fn sine(hz: f64, rate: u32, pulls: &mut Vec<(i64, usize)>) -> impl FnMut(i64, usize) -> Vec<Vec<f32>> + '_ {
|
||||
move |start, frames| {
|
||||
pulls.push((start, frames));
|
||||
let ch: Vec<f32> = (0..frames).map(|i| (2.0 * std::f64::consts::PI * hz * (start + i as i64) as f64 / rate as f64).sin() as f32).collect();
|
||||
vec![ch.clone(), ch]
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(from: u32, to: u32, blocks: &[usize], total: usize) -> (Vec<f32>, Vec<(i64, usize)>) {
|
||||
let mut r = StreamResampler::new(from, to);
|
||||
let mut pulls = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0i64;
|
||||
let mut k = 0;
|
||||
while (pos as usize) < total {
|
||||
let n = blocks[k % blocks.len()].min(total - pos as usize);
|
||||
let b = r.process(pos, n, sine(1000.0, from, &mut pulls));
|
||||
assert_eq!(b.len(), 2);
|
||||
assert!(b.iter().all(|c| c.len() == n));
|
||||
out.extend_from_slice(&b[0]);
|
||||
pos += n as i64;
|
||||
k += 1;
|
||||
}
|
||||
(out, pulls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_the_same_however_the_stream_is_cut() {
|
||||
for (from, to) in [(48_000, 44_100), (44_100, 48_000), (48_000, 96_000), (96_000, 48_000)] {
|
||||
let (whole, _) = convert(from, to, &[20_000], 20_000);
|
||||
for blocks in [&[512][..], &[333], &[1, 7, 512, 64]] {
|
||||
let (cut, _) = convert(from, to, blocks, 20_000);
|
||||
assert_eq!(cut, whole, "{from}→{to} in blocks {blocks:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tone_converts_to_the_same_tone() {
|
||||
// 1 kHz at 48 kHz played on a 44.1 kHz device: within linear interpolation's error of the
|
||||
// ideal tone, one source sample late
|
||||
let (from, to) = (48_000u32, 44_100u32);
|
||||
let (out, _) = convert(from, to, &[512], 20_000);
|
||||
for (o, x) in out.iter().enumerate().skip(2) {
|
||||
let t = o as f64 / to as f64 - 1.0 / from as f64;
|
||||
let want = (2.0 * std::f64::consts::PI * 1000.0 * t).sin() as f32;
|
||||
assert!((x - want).abs() < 3e-3, "frame {o}: {x} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_is_pulled_as_one_continuous_stream() {
|
||||
for (from, to) in [(48_000, 44_100), (44_100, 48_000)] {
|
||||
let (_, pulls) = convert(from, to, &[512, 333], 20_000);
|
||||
for w in pulls.windows(2) {
|
||||
assert_eq!(w[0].0 + w[0].1 as i64, w[1].0, "{from}→{to}: {:?} then {:?}", w[0], w[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seek_starts_afresh_and_hostile_input_stays_finite() {
|
||||
let mut r = StreamResampler::new(48_000, 44_100);
|
||||
let mut pulls = Vec::new();
|
||||
let _ = r.process(0, 512, sine(1000.0, 48_000, &mut pulls));
|
||||
let _ = r.process(1_000_000, 512, sine(1000.0, 48_000, &mut pulls));
|
||||
assert_eq!(pulls.last().map(|p| p.0), Some(1_000_000 * 48_000 / 44_100 - 1));
|
||||
// a mixer returning too few channels and samples, and zero rates
|
||||
let mut r = StreamResampler::new(0, 0);
|
||||
let b = r.process(5, 64, |_, _| vec![vec![1.0; 3]]);
|
||||
assert_eq!(b.len(), 1);
|
||||
assert!(b[0].iter().all(|x| x.is_finite()));
|
||||
assert!(r.process(0, 0, |_, _| Vec::new()).iter().all(Vec::is_empty));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
//! Client for the desktop app's JSON-lines control protocol. A connection per request in flight,
|
||||
//! reused once its reply is in; reconnect on failure.
|
||||
//! Client for the desktop app's JSON-lines control protocol. One connection, reconnect on failure.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
|
@ -11,19 +10,11 @@ use tokio::sync::Mutex;
|
|||
|
||||
use crate::AutomationError;
|
||||
|
||||
/// How often a blocking export's app job is polled.
|
||||
const JOB_POLL: Duration = Duration::from_millis(250);
|
||||
/// Idle connections kept for reuse; the app serves at most 16 at once.
|
||||
const MAX_IDLE: usize = 4;
|
||||
|
||||
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
|
||||
|
||||
pub struct BridgeClient {
|
||||
addr: String,
|
||||
/// Connections with no request in flight. A request takes one (or opens one) for its whole
|
||||
/// round trip and puts it back only once the reply is read, so a slow or abandoned request
|
||||
/// never holds up the others or leaves its late reply for the next one to read (#1538).
|
||||
idle: Mutex<Vec<Conn>>,
|
||||
conn: Mutex<Option<Conn>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
|
|
@ -35,56 +26,25 @@ impl BridgeClient {
|
|||
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
|
||||
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
|
||||
}
|
||||
Ok(Self { addr, idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
|
||||
}
|
||||
|
||||
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
|
||||
/// `wait: true`, see [`crate::long_job::LONG_COMMANDS`]) is started as an app job and polled
|
||||
/// here until it finishes, so the app keeps repainting (its status bar shows the progress) and
|
||||
/// answering other requests, and no request waits on the control server's 60 s reply limit
|
||||
/// (#91, #92). The reply is the same as a blocking export's: `{job, path, result}`, or the
|
||||
/// export's error.
|
||||
pub async fn execute(&self, id: &str, mut params: Value) -> Result<Value, AutomationError> {
|
||||
if !crate::long_job::is_long(id, ¶ms) {
|
||||
return self.call("engine.execute", json!({"command": id, "params": params})).await;
|
||||
}
|
||||
if let Some(p) = params.as_object_mut() {
|
||||
p.insert("wait".into(), json!(false));
|
||||
}
|
||||
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, 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, None);
|
||||
};
|
||||
if j["finished"].as_bool() == Some(true) {
|
||||
break (j["result"].clone(), j.get("offlineMedia").cloned());
|
||||
}
|
||||
};
|
||||
if let Some(e) = result.get("error").and_then(Value::as_str) {
|
||||
return Err(AutomationError::App(format!("export failed: {e}")));
|
||||
}
|
||||
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)
|
||||
Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) })
|
||||
}
|
||||
|
||||
/// Call a control method; returns `result` or the app's error.
|
||||
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
for attempt in 0..2 {
|
||||
// A retry gets a fresh connection: the failed one may have been closed by an app restart.
|
||||
let idle = if attempt == 0 { self.idle.lock().await.pop() } else { None };
|
||||
let mut conn = match idle {
|
||||
Some(c) => c,
|
||||
None => self.connect().await?,
|
||||
};
|
||||
if guard.is_none() {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
|
||||
let (r, w) = s.into_split();
|
||||
*guard = Some((BufReader::new(r), w));
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(conn) = guard.as_mut() else {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
};
|
||||
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
|
||||
let res: Result<Value, AutomationError> = async {
|
||||
conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;
|
||||
|
|
@ -98,188 +58,19 @@ impl BridgeClient {
|
|||
.await;
|
||||
match res {
|
||||
Ok(v) => {
|
||||
let mut idle = self.idle.lock().await;
|
||||
if idle.len() < MAX_IDLE {
|
||||
idle.push(conn);
|
||||
}
|
||||
drop(idle);
|
||||
return if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()))
|
||||
};
|
||||
}
|
||||
Err(_) if attempt == 0 => {}
|
||||
Err(e) if attempt == 0 => {
|
||||
*guard = None;
|
||||
let _ = e;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(AutomationError::Bridge("unreachable".into()))
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<Conn, AutomationError> {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
|
||||
let (r, w) = s.into_split();
|
||||
Ok((BufReader::new(r), w))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// 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<Mutex<Vec<Value>>>) {
|
||||
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<Value>) -> (String, Arc<Mutex<Vec<Value>>>) {
|
||||
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()));
|
||||
let log = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let (s, _) = listener.accept().await.unwrap();
|
||||
let (r, mut w) = s.into_split();
|
||||
let mut lines = BufReader::new(r).lines();
|
||||
let mut polls = 0;
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
log.lock().await.push(req.clone());
|
||||
let reply = match req["params"]["command"].as_str() {
|
||||
Some("file.exportMedia") => json!({"job": 7, "path": "/tmp/out.mov"}),
|
||||
Some("jobs.list") => {
|
||||
polls += 1;
|
||||
let finished = polls >= 3;
|
||||
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),
|
||||
};
|
||||
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": reply}));
|
||||
w.write_all(line.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
(addr, seen)
|
||||
}
|
||||
|
||||
/// A blocking bridge export runs as an app job (#91, #92): the app never sees `wait: true` (it
|
||||
/// would encode on its UI thread and the reply would time out after 60 s), and the caller still
|
||||
/// gets the finished result.
|
||||
#[tokio::test]
|
||||
async fn a_blocking_export_is_polled_as_a_job() {
|
||||
let (addr, seen) = fake_app(json!({"frames": 642})).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, json!({"job": 7, "path": "/tmp/out.mov", "result": {"frames": 642}}));
|
||||
let seen = seen.lock().await;
|
||||
assert_eq!(seen[0]["params"]["params"]["wait"], json!(false));
|
||||
assert!(seen.iter().all(|q| q["params"]["params"]["wait"] != json!(true)));
|
||||
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "file.exportMedia").count(), 1, "started once");
|
||||
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;
|
||||
let b = BridgeClient::new(addr).unwrap();
|
||||
let e = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap_err();
|
||||
assert!(e.to_string().contains("export failed: encode: disk full"), "{e}");
|
||||
}
|
||||
|
||||
/// A stand-in for the app that serves any number of connections and answers each request on
|
||||
/// its own, as the app does: `slow` after `slow_reply` (never when `None`), anything else at
|
||||
/// once with its command id. The ids of the requests it receives are sent to the returned channel.
|
||||
async fn slow_app(slow_reply: Option<Duration>) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let (seen, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((s, _)) = listener.accept().await {
|
||||
let seen = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let (r, w) = s.into_split();
|
||||
let w = Arc::new(Mutex::new(w));
|
||||
let mut lines = BufReader::new(r).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
let cmd = req["params"]["command"].as_str().unwrap_or_default().to_string();
|
||||
let _ = seen.send(cmd.clone());
|
||||
let delay = if cmd == "slow" { slow_reply } else { Some(Duration::ZERO) };
|
||||
let w = w.clone();
|
||||
tokio::spawn(async move {
|
||||
let Some(d) = delay else { return };
|
||||
tokio::time::sleep(d).await;
|
||||
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": cmd}));
|
||||
let _ = w.lock().await.write_all(line.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(addr, rx)
|
||||
}
|
||||
|
||||
/// A command the app is slow to answer holds up only itself: a later call is answered at once
|
||||
/// instead of queueing behind it, and behind every call queued after it (#1538).
|
||||
#[tokio::test]
|
||||
async fn a_slow_command_does_not_block_later_calls() {
|
||||
let (addr, mut seen) = slow_app(None).await;
|
||||
let b = Arc::new(BridgeClient::new(addr).unwrap());
|
||||
let slow = tokio::spawn({
|
||||
let b = b.clone();
|
||||
async move { b.execute("slow", json!({})).await }
|
||||
});
|
||||
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
|
||||
let fast = tokio::time::timeout(Duration::from_secs(5), b.execute("fast", json!({}))).await;
|
||||
assert_eq!(fast.expect("the later call waited on the slow one").unwrap(), json!("fast"));
|
||||
slow.abort();
|
||||
}
|
||||
|
||||
/// A call given up while it waits leaves no late reply on a connection for the next call to read
|
||||
/// as its own (#1538).
|
||||
#[tokio::test]
|
||||
async fn an_abandoned_call_leaves_no_late_reply_behind() {
|
||||
let (addr, mut seen) = slow_app(Some(Duration::from_millis(100))).await;
|
||||
let b = Arc::new(BridgeClient::new(addr).unwrap());
|
||||
let slow = tokio::spawn({
|
||||
let b = b.clone();
|
||||
async move { b.execute("slow", json!({})).await }
|
||||
});
|
||||
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
|
||||
slow.abort();
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(b.execute("fast", json!({})).await.unwrap(), json!("fast"));
|
||||
}
|
||||
|
||||
/// Everything else is forwarded unchanged.
|
||||
#[tokio::test]
|
||||
async fn other_commands_are_forwarded() {
|
||||
let (addr, seen) = fake_app(Value::Null).await;
|
||||
let b = BridgeClient::new(addr).unwrap();
|
||||
b.execute("file.exportMedia", json!({"path": "/tmp/out.mov"})).await.unwrap();
|
||||
b.execute("sequence.inspect", json!({})).await.unwrap();
|
||||
let seen = seen.lock().await;
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert_eq!(seen[1]["params"]["command"], "sequence.inspect");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ 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}"))]);
|
||||
|
|
@ -80,9 +79,6 @@ 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())]);
|
||||
}
|
||||
|
|
@ -111,11 +107,10 @@ impl FilmcraftMcp {
|
|||
}
|
||||
|
||||
/// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered
|
||||
/// stills of an image sequence (`<stem><3+ digits>.<same extension>`, only when the output is a
|
||||
/// PNG/TIFF/BMP/JPEG/Targa/DPX sequence) and the caption sidecar
|
||||
/// stills of an image sequence (`<stem><digits>.<same extension>`) and the caption sidecar
|
||||
/// (`<stem>.srt` / `<stem>.vtt`), each only if it was written since `since`. Other files that
|
||||
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, `<stem>1.mp4` beside a movie, …) are
|
||||
/// never touched, even when they were saved during the export.
|
||||
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, …) are never touched, even when they
|
||||
/// were saved during the export.
|
||||
pub fn remove_partial(path: &str, since: SystemTime) {
|
||||
let p = Path::new(path);
|
||||
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
|
||||
|
|
@ -144,19 +139,9 @@ fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool {
|
|||
if let Some(sidecar) = rest.strip_prefix('.') {
|
||||
return sidecar.eq_ignore_ascii_case("srt") || sidecar.eq_ignore_ascii_case("vtt");
|
||||
}
|
||||
// image sequence: the frame number (at least three digits, like `image_sequence_path`), then the
|
||||
// output's own extension; movies and audio are a single file, so numbered neighbours are not theirs
|
||||
if !is_sequence_extension(ext) {
|
||||
return false;
|
||||
}
|
||||
// image sequence: one or more digits, then the output's own extension
|
||||
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
|
||||
digits >= 3 && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
|
||||
}
|
||||
|
||||
/// Whether an export to a file with extension `ext` writes an image sequence (one still per frame).
|
||||
fn is_sequence_extension(ext: &str) -> bool {
|
||||
use filmcraft_engine::export::Format;
|
||||
Format::from_name(ext).is_some_and(Format::is_image_sequence)
|
||||
digits > 0 && !ext.is_empty() && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -185,50 +170,6 @@ mod tests {
|
|||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_files_beside_a_movie_are_kept() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-movie-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let started = SystemTime::now();
|
||||
// a movie export writes one file: `clip1.mp4` and `clip002.mp4` are other exports
|
||||
let ours = ["clip.mp4", "clip.srt"];
|
||||
let theirs = ["clip1.mp4", "clip002.mp4", "clip2.mov"];
|
||||
for f in ours.iter().chain(theirs.iter()) {
|
||||
std::fs::write(dir.join(f), b"x").unwrap();
|
||||
}
|
||||
remove_partial(&dir.join("clip.mp4").to_string_lossy(), started);
|
||||
for f in ours {
|
||||
assert!(!dir.join(f).exists(), "{f} should be removed");
|
||||
}
|
||||
for f in theirs {
|
||||
assert!(dir.join(f).exists(), "{f} must not be removed");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_frames_have_at_least_three_digits() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-digits-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let started = SystemTime::now();
|
||||
// frames are numbered with at least three digits (`shot000.tiff`); `shot2.tiff` is not one
|
||||
let ours = ["shot.tiff", "shot000.tiff", "shot1234.tiff"];
|
||||
let theirs = ["shot2.tiff", "shot12.tiff"];
|
||||
for f in ours.iter().chain(theirs.iter()) {
|
||||
std::fs::write(dir.join(f), b"x").unwrap();
|
||||
}
|
||||
remove_partial(&dir.join("shot.tiff").to_string_lossy(), started);
|
||||
for f in ours {
|
||||
assert!(!dir.join(f).exists(), "{f} should be removed");
|
||||
}
|
||||
for f in theirs {
|
||||
assert!(dir.join(f).exists(), "{f} must not be removed");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_older_than_the_export_are_kept() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-old-{}", std::process::id()));
|
||||
|
|
|
|||
|
|
@ -139,30 +139,6 @@ pub struct TypeParams {
|
|||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct MediaImportParams {
|
||||
/// Absolute paths to media files (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).
|
||||
pub paths: Vec<String>,
|
||||
/// Bin ID to import into (optional).
|
||||
#[serde(default)]
|
||||
pub bin: Option<u64>,
|
||||
/// Import numbered stills as an image sequence (optional, default false).
|
||||
#[serde(default)]
|
||||
pub image_sequence: Option<bool>,
|
||||
}
|
||||
|
||||
/// `file.import` params for a `media_import` call.
|
||||
fn media_import_params(p: MediaImportParams) -> Value {
|
||||
let mut params = json!({"paths": p.paths});
|
||||
if let Some(bin) = p.bin {
|
||||
params["bin"] = json!(bin);
|
||||
}
|
||||
if let Some(seq) = p.image_sequence {
|
||||
params["imageSequence"] = json!(seq);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct ElementsParams {
|
||||
/// Only element ids starting with this prefix (e.g. `timeline.clip.`).
|
||||
|
|
@ -273,7 +249,7 @@ impl FilmcraftMcp {
|
|||
.await
|
||||
.map_err(join_error)?
|
||||
}
|
||||
Backend::Bridge(b) => b.execute(id, params).await,
|
||||
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -405,8 +381,9 @@ impl FilmcraftMcp {
|
|||
description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).",
|
||||
annotations(read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn media_import(&self, Parameters(p): Parameters<MediaImportParams>) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.run("file.import", media_import_params(p)).await)
|
||||
async fn media_import(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
|
||||
let paths: Vec<&str> = p.text.split('\n').map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
wrap(self.run("file.import", json!({"paths": paths})).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -420,13 +397,14 @@ impl FilmcraftMcp {
|
|||
Backend::Headless(s) => {
|
||||
let s = s.clone();
|
||||
let r = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AutomationError> {
|
||||
let g = s.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
// read-only: render at `seconds` without moving the playhead (#114)
|
||||
let t = p.seconds.map_or_else(|| g.playhead(), filmcraft_time::Tick::from_seconds_f64);
|
||||
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(sec) = p.seconds {
|
||||
g.set_playhead(filmcraft_time::Tick::from_seconds_f64(sec));
|
||||
}
|
||||
let (w, h) =
|
||||
g.active_sequence().map(|q| (q.settings.width, q.settings.height)).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
|
||||
let scale = render_scale(max, w, h);
|
||||
let img = g.try_render_program_at(scale, t).map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
let scale = (max as f32 / w.max(h) as f32).min(1.0);
|
||||
let img = g.render_program(scale).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
|
||||
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
|
||||
})
|
||||
.await
|
||||
|
|
@ -437,31 +415,12 @@ impl FilmcraftMcp {
|
|||
})
|
||||
}
|
||||
Backend::Bridge(b) => {
|
||||
let Some(sec) = p.seconds else {
|
||||
return self.screenshot(&b.clone(), Some("Program".into()), max).await;
|
||||
};
|
||||
// The Program monitor shows the playhead's frame, so the live playhead has to move
|
||||
// for the screenshot; put it (and the selection, which may follow it) back after,
|
||||
// so the tool stays read-only (#114).
|
||||
let before = match self.run("sequence.inspect", json!({})).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Ok(fail(e)),
|
||||
};
|
||||
if let Err(e) = self.run("playhead.set", json!({"seconds": sec})).await {
|
||||
if let Some(sec) = p.seconds
|
||||
&& let Err(e) = b.call("engine.execute", json!({"command": "playhead.set", "params": {"seconds": sec}})).await
|
||||
{
|
||||
return Ok(fail(e));
|
||||
}
|
||||
let shot = self.screenshot(&b.clone(), Some("Program".into()), max).await;
|
||||
if let Some(t) = before.get("playhead").and_then(Value::as_i64) {
|
||||
let _ = self.run("playhead.set", json!({"time": t})).await;
|
||||
}
|
||||
// only when it changed: selecting also leaves trim mode
|
||||
let now = self.run("sequence.inspect", json!({})).await.ok();
|
||||
if let Some(sel) = before.get("selection").filter(|v| v.is_array())
|
||||
&& now.as_ref().and_then(|n| n.get("selection")) != Some(sel)
|
||||
{
|
||||
let _ = self.run("timeline.select", json!({"clips": sel})).await;
|
||||
}
|
||||
shot
|
||||
self.screenshot(&b.clone(), Some("Program".into()), max).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -615,8 +574,8 @@ impl ServerHandler for FilmcraftMcp {
|
|||
return Err(McpError::invalid_params(m, None));
|
||||
}
|
||||
// A blocking export reports progress and can be cancelled (docs/agents.md § Long exports).
|
||||
// Bridge mode too: the app runs it as a job, so it stays responsive (#91, #92).
|
||||
if request.name == "command_run"
|
||||
&& matches!(&*self.backend, Backend::Headless(_))
|
||||
&& let Some(a) = &request.arguments
|
||||
&& let Some(id) = a.get("id").and_then(Value::as_str)
|
||||
{
|
||||
|
|
@ -671,26 +630,10 @@ impl ServerHandler for FilmcraftMcp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Render scale that fits the longest side of a `w` x `h` sequence into `max_side` (never upscales; 0 = no limit, as in `png_rgba`).
|
||||
fn render_scale(max_side: u32, w: u32, h: u32) -> f32 {
|
||||
if max_side == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
(max_side as f32 / w.max(h).max(1) as f32).min(1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_scale_zero_means_no_limit() {
|
||||
assert_eq!(render_scale(0, 1920, 1080), 1.0);
|
||||
assert_eq!(render_scale(960, 1920, 1080), 0.5);
|
||||
assert_eq!(render_scale(960, 640, 360), 1.0);
|
||||
assert_eq!(render_scale(960, 0, 0), 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_commands_and_render() {
|
||||
let mut s = Session::default();
|
||||
|
|
@ -702,27 +645,6 @@ mod tests {
|
|||
assert_ne!(r.is_error, Some(true));
|
||||
}
|
||||
|
||||
/// `render_frame` / `render_preview` are annotated read-only (#114): rendering at `seconds`
|
||||
/// leaves the playhead, and the selection that follows it, where they were, and still renders
|
||||
/// the frame at `seconds`.
|
||||
#[tokio::test]
|
||||
async fn rendering_at_a_time_does_not_move_the_playhead() {
|
||||
let m = FilmcraftMcp::headless(demo());
|
||||
m.run("sequence.selectionFollowsPlayhead", json!({"on": true})).await.unwrap();
|
||||
m.run("playhead.set", json!({"seconds": 1.0})).await.unwrap();
|
||||
let before = m.run("sequence.inspect", json!({})).await.unwrap();
|
||||
let png = |r: CallToolResult| serde_json::to_string(&r.content).unwrap();
|
||||
let at_playhead = png(m.render_frame(Parameters(RenderParams { seconds: None, max_side: Some(64) })).await.unwrap());
|
||||
for seconds in [5.0, 8.0] {
|
||||
let r = m.render_frame(Parameters(RenderParams { seconds: Some(seconds), max_side: Some(64) })).await.unwrap();
|
||||
assert_ne!(png(r), at_playhead, "renders the frame at {seconds} s");
|
||||
m.render_preview(Parameters(RenderParams { seconds: Some(seconds), max_side: Some(64) })).await.unwrap();
|
||||
}
|
||||
let after = m.run("sequence.inspect", json!({})).await.unwrap();
|
||||
assert_eq!(after["playhead"], before["playhead"]);
|
||||
assert_eq!(after["selection"], before["selection"]);
|
||||
}
|
||||
|
||||
/// One request/response exchange over `serve_io`, as an MCP client sees it.
|
||||
struct Client {
|
||||
tx: tokio::io::DuplexStream,
|
||||
|
|
@ -758,21 +680,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// #499: `media_import` takes `paths` (a required array), and forwards `bin` / `image_sequence` to `file.import`.
|
||||
#[test]
|
||||
fn media_import_schema_and_params() {
|
||||
let m = FilmcraftMcp::headless(Session::default());
|
||||
let def = m.tool_router.get("media_import").unwrap();
|
||||
let schema = Value::Object((*def.input_schema).clone());
|
||||
assert_eq!(schema["properties"]["paths"]["type"], "array", "{schema}");
|
||||
assert!(schema["required"].as_array().unwrap().iter().any(|r| r == "paths"), "{schema}");
|
||||
let p: MediaImportParams = serde_json::from_value(json!({"paths": ["/a.png"], "bin": 7, "image_sequence": true})).unwrap();
|
||||
assert_eq!(media_import_params(p), json!({"paths": ["/a.png"], "bin": 7, "imageSequence": true}));
|
||||
let p: MediaImportParams = serde_json::from_value(json!({"paths": ["/a.wav"]})).unwrap();
|
||||
assert_eq!(media_import_params(p), json!({"paths": ["/a.wav"]}));
|
||||
assert!(serde_json::from_value::<MediaImportParams>(json!({"text": "/a.wav"})).is_err());
|
||||
}
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
|
|
@ -911,48 +818,32 @@ mod tests {
|
|||
c.send(&export(1, "quiet.mp4", None, 0.5)).await;
|
||||
let m = c.next().await;
|
||||
assert_eq!((m["id"].as_u64(), &m["result"]["isError"]), (Some(1), &json!(false)), "{m}");
|
||||
// progress, strictly increasing, with a total; ping answered while the export runs. The
|
||||
// job is polled every 100 ms, so a fast machine finishes a short export within one poll:
|
||||
// a longer one is tried until the export outlasts a few polls.
|
||||
let (mut notes, mut pong) = (0, false);
|
||||
for (attempt, seconds) in [1.0, 4.0, 16.0].into_iter().enumerate() {
|
||||
let (id, ping, name) = (20 + attempt as u64, 30 + attempt as u64, format!("a{attempt}.mp4"));
|
||||
c.send(&export(id, &name, Some(json!("tok")), seconds)).await;
|
||||
let (mut last, mut pinged) = (-1.0, false);
|
||||
(notes, pong) = (0, false);
|
||||
let done = loop {
|
||||
let m = c.next().await;
|
||||
if m["method"] == "notifications/progress" {
|
||||
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
|
||||
let p = m["params"]["progress"].as_f64().unwrap();
|
||||
assert!(p > last && m["params"]["total"].as_f64().unwrap() >= p, "{m}");
|
||||
last = p;
|
||||
notes += 1;
|
||||
if !pinged {
|
||||
pinged = true;
|
||||
c.send(&json!({"jsonrpc": "2.0", "id": ping, "method": "ping"}).to_string()).await;
|
||||
}
|
||||
} else if m["id"] == ping {
|
||||
pong = true;
|
||||
} else if m["id"] == id {
|
||||
break m;
|
||||
// progress, strictly increasing, with a total; ping answered while the export runs
|
||||
c.send(&export(2, "a.mp4", Some(json!("tok")), 1.0)).await;
|
||||
let (mut last, mut notes, mut pinged, mut pong) = (-1.0, 0, false, false);
|
||||
let done = loop {
|
||||
let m = c.next().await;
|
||||
if m["method"] == "notifications/progress" {
|
||||
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
|
||||
let p = m["params"]["progress"].as_f64().unwrap();
|
||||
assert!(p > last && m["params"]["total"].as_f64().unwrap() >= p, "{m}");
|
||||
last = p;
|
||||
notes += 1;
|
||||
if !pinged {
|
||||
pinged = true;
|
||||
c.send(r#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#).await;
|
||||
}
|
||||
};
|
||||
assert_eq!(done["result"]["isError"], false, "{done}");
|
||||
assert!(notes >= 1, "a finished export reports its progress at least once");
|
||||
assert!(dir.join(&name).exists());
|
||||
if notes >= 2 && pong {
|
||||
break;
|
||||
} else if m["id"] == 3 {
|
||||
pong = true;
|
||||
} else if m["id"] == 2 {
|
||||
break m;
|
||||
}
|
||||
// the ping's answer is still on its way: read it so it cannot be taken for a later reply
|
||||
while pinged && !pong {
|
||||
pong = c.next().await["id"] == ping;
|
||||
}
|
||||
pong = false;
|
||||
}
|
||||
};
|
||||
assert_eq!(done["result"]["isError"], false, "{done}");
|
||||
assert!(notes >= 2 && pong, "{notes} notifications, ping answered during the export: {pong}");
|
||||
assert!(dir.join("a.mp4").exists());
|
||||
// cancel after the first notification: no response, no partial file
|
||||
c.send(&export(4, "b.mp4", Some(json!(7)), 20.0)).await;
|
||||
c.send(&export(4, "b.mp4", Some(json!(7)), 8.0)).await;
|
||||
loop {
|
||||
let m = c.next().await;
|
||||
assert_ne!(m["id"], 4, "finished before the cancel: {m}");
|
||||
|
|
|
|||
|
|
@ -70,15 +70,12 @@ 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; 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
|
||||
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
|
||||
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 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,
|
||||
(`Decoder::stats()`) and all-core fps for 1080p 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
|
||||
|
|
|
|||
|
|
@ -1,45 +1,35 @@
|
|||
//! CDEF (7.15).
|
||||
|
||||
use crate::frame::{FrameBuf, RowBand};
|
||||
use crate::par::Pool;
|
||||
use crate::postfilter::bands;
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
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). 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<FrameBuf> {
|
||||
/// equals CurrFrame).
|
||||
pub(crate) fn apply(fs: &FrameState) -> Option<FrameBuf> {
|
||||
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 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;
|
||||
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;
|
||||
}
|
||||
r += 2;
|
||||
c += 2;
|
||||
}
|
||||
any
|
||||
});
|
||||
drop(planes);
|
||||
any.contains(&true).then_some(out)
|
||||
r += 2;
|
||||
}
|
||||
any.then_some(out)
|
||||
}
|
||||
|
||||
fn cdef_block(fs: &FrameState, out: &mut [RowBand; 3], r: usize, c: usize, idx: usize) -> bool {
|
||||
fn cdef_block(fs: &FrameState, out: &mut FrameBuf, 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);
|
||||
|
|
@ -135,7 +125,7 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
|
|||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn filter(fs: &FrameState, out: &mut [RowBand; 3], plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
|
||||
fn filter(fs: &FrameState, out: &mut FrameBuf, 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;
|
||||
|
|
@ -179,7 +169,7 @@ fn filter(fs: &FrameState, out: &mut [RowBand; 3], plane: usize, r: usize, c: us
|
|||
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[plane];
|
||||
let dst = &mut out.planes[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];
|
||||
|
|
@ -240,12 +230,7 @@ fn filter(fs: &FrameState, out: &mut [RowBand; 3], plane: usize, r: usize, c: us
|
|||
/// 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<const W: usize>(
|
||||
buf: &[i32; PAD_STRIDE * PAD_STRIDE],
|
||||
taps: &[(isize, i32, i32, u32); 12],
|
||||
dst: &mut RowBand,
|
||||
(y0, x0, h): (usize, usize, usize),
|
||||
) {
|
||||
fn filter_rows<const W: usize>(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &[(isize, i32, i32, u32); 12], dst: &mut Plane, (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;
|
||||
|
|
|
|||
|
|
@ -106,48 +106,6 @@ 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<RowBand<'_>> {
|
||||
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.
|
||||
|
|
@ -200,16 +158,6 @@ 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 }
|
||||
}
|
||||
|
|
@ -337,40 +285,3 @@ 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::<Vec<_>>(), [(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::<Vec<_>>(), [(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::<Vec<_>>(), [(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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,20 @@
|
|||
//! 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};
|
||||
|
||||
/// 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) {
|
||||
/// 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) {
|
||||
let mut t = Timer::start();
|
||||
let lvl = fs.fh.lf.level;
|
||||
if (lvl[0] != 0 || lvl[1] != 0) && !draft {
|
||||
loop_filter(fs, pool);
|
||||
loop_filter(fs);
|
||||
}
|
||||
stats.add(Stage::LoopFilter, t.lap());
|
||||
let cdef = if draft { None } else { crate::cdef::apply(fs, pool) };
|
||||
let cdef = if draft { None } else { crate::cdef::apply(fs) };
|
||||
stats.add(Stage::Cdef, t.lap());
|
||||
let (up_cur, up_cdef) = if fs.fh.use_superres {
|
||||
let c = crate::restoration::upscale(fs, &fs.cur);
|
||||
|
|
@ -38,7 +25,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), pool)
|
||||
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur))
|
||||
} else {
|
||||
up_cdef.unwrap_or(up_cur)
|
||||
};
|
||||
|
|
@ -48,102 +35,28 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, pool: &crate::
|
|||
// ---------------------------------------------------------------------------------------------
|
||||
// Loop filter (7.14)
|
||||
|
||||
/// 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<usize> = (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<u16>; 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<u16>; 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.cur = cur;
|
||||
}
|
||||
|
||||
/// 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<usize>| {
|
||||
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) {
|
||||
fn edge(fs: &mut FrameState, 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;
|
||||
|
|
@ -189,40 +102,16 @@ fn edge(fs: &FrameState, band: &mut RowBand, above: Option<&mut RowBand>, plane:
|
|||
return;
|
||||
}
|
||||
let t = LfThresh::new(limit, blimit, thresh, fs.bit_depth);
|
||||
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 pl = &mut fs.cur.planes[plane];
|
||||
let stride = pl.stride;
|
||||
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
|
||||
let base = (yp - band.y0) * stride + xp;
|
||||
let base = yp * stride + xp;
|
||||
let chroma = plane > 0;
|
||||
for k in 0..4 {
|
||||
sample_filter(band.data, base + k * along, step, chroma, filter_size, t);
|
||||
sample_filter(&mut pl.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;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane, RowBand};
|
||||
use crate::par::Pool;
|
||||
use crate::postfilter::bands;
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
|
||||
|
|
@ -72,66 +70,56 @@ impl LrCtx<'_> {
|
|||
}
|
||||
|
||||
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
|
||||
/// 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 {
|
||||
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> 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;
|
||||
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;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
for plane in 0..fs.num_planes {
|
||||
let rtype_frame = fh.lr.frame_restoration_type[plane];
|
||||
if rtype_frame == RESTORE_NONE as u8 {
|
||||
continue;
|
||||
}
|
||||
});
|
||||
drop(planes);
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
lr
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, 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];
|
||||
|
|
@ -175,7 +163,7 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usi
|
|||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, 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];
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use common::*;
|
|||
#[test]
|
||||
#[ignore]
|
||||
fn adhoc_compare() {
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(path) = std::env::var_os("AV1_IVF") else { return };
|
||||
let path = std::path::PathBuf::from(path);
|
||||
let pics = decode_all(&path).unwrap();
|
||||
|
|
|
|||
|
|
@ -6,32 +6,8 @@ use filmcraft_av1::{Decoder, Picture};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// ffmpeg with libsvtav1 (fixtures) and libdav1d (reference), or `None` after reporting a skip.
|
||||
pub fn ffmpeg() -> Option<PathBuf> {
|
||||
ffmpeg_with(&[("-encoders", "libsvtav1"), ("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// ffmpeg with libdav1d, for checks that decode existing files only.
|
||||
pub fn ffmpeg_dav1d() -> Option<PathBuf> {
|
||||
ffmpeg_with(&[("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// ffmpeg with libaom (4:2:2 and 4:4:4 fixtures, which SVT-AV1 cannot make) and libdav1d.
|
||||
pub fn ffmpeg_libaom() -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?;
|
||||
for &(list, lib) in needs {
|
||||
let out = Command::new(&ff).args(["-hide_banner", list]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();
|
||||
if !out.contains(lib) {
|
||||
filmcraft_testkit::skip("av1 oracle", &format!("ffmpeg has no {lib} (see `ffmpeg {list}`)"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(ff)
|
||||
filmcraft_testkit::ffmpeg_or_skip("av1 oracle")
|
||||
}
|
||||
|
||||
pub fn fixture_dir() -> PathBuf {
|
||||
|
|
@ -43,11 +19,9 @@ 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 (libsvtav1 unless [`Spec::encoder`] says
|
||||
/// otherwise) into IVF.
|
||||
/// An AV1 fixture: a lavfi source encoded by ffmpeg's libsvtav1 into IVF.
|
||||
pub struct Spec {
|
||||
pub name: &'static str,
|
||||
pub encoder: &'static str,
|
||||
pub lavfi: String,
|
||||
pub frames: u32,
|
||||
pub pix_fmt: &'static str,
|
||||
|
|
@ -57,12 +31,7 @@ pub struct Spec {
|
|||
|
||||
impl Spec {
|
||||
pub fn new(name: &'static str, lavfi: impl Into<String>, frames: u32, pix_fmt: &'static str, enc: &[&str]) -> Spec {
|
||||
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
|
||||
Spec { name, lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +41,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", spec.encoder];
|
||||
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"];
|
||||
args.extend(spec.enc.iter().map(String::as_str));
|
||||
args.extend(["-f", "ivf", t.as_str()]);
|
||||
run(ff, &args);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const VECTORS: &[&str] =
|
|||
|
||||
#[test]
|
||||
fn conformance_vectors() {
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
for name in VECTORS {
|
||||
let Some(path) = test_vector(name) else { return };
|
||||
check_file(&ff, name, &path);
|
||||
|
|
@ -21,7 +21,7 @@ fn conformance_vectors() {
|
|||
#[test]
|
||||
#[ignore]
|
||||
fn conformance_vectors_extended() {
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let list: Vec<String> = match std::env::var("AV1_VECTORS") {
|
||||
Ok(v) => v.split(',').map(str::to_string).collect(),
|
||||
Err(_) => EXTENDED.iter().map(|s| s.to_string()).collect(),
|
||||
|
|
|
|||
|
|
@ -101,19 +101,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ fn run(frames: &[Vec<u8>], threads: usize) -> Run {
|
|||
}
|
||||
|
||||
fn bench(spec: &Spec) {
|
||||
// AV1_PERF_ONLY=<substring>[,<substring>...] 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))) {
|
||||
// AV1_PERF_ONLY=<substring> limits the run to matching fixtures.
|
||||
if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !spec.name.contains(f.as_str())) {
|
||||
return;
|
||||
}
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
|
|
@ -68,6 +68,7 @@ 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)",
|
||||
|
|
@ -78,35 +79,24 @@ fn bench(spec: &Spec) {
|
|||
cpu_fps,
|
||||
b.shown as f64 / b.wall
|
||||
);
|
||||
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<Run> = 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);
|
||||
let total = b.stats.total_secs();
|
||||
let mut line = String::from(" ms/frame:");
|
||||
for s in Stage::ALL {
|
||||
let v = stats.secs(s);
|
||||
let v = b.stats.secs(s);
|
||||
if v > 0.0005 {
|
||||
line += &format!(" {} {:.2} ({:.0}%)", s.name(), 1e3 * v / n, 100.0 * v / total);
|
||||
}
|
||||
}
|
||||
line
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -120,9 +110,4 @@ 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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
[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 }
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,542 +0,0 @@
|
|||
//! 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<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Whether `head` starts like an AVI file: `RIFF` <size> `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<u8>,
|
||||
}
|
||||
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
/// 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<BitmapInfo>,
|
||||
pub audio: Option<WaveFormat>,
|
||||
/// `strn`.
|
||||
pub name: Option<String>,
|
||||
pub chunks: Vec<Chunk>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
/// Stream index of a `movi` chunk id (`00dc`, `01wb`…): its first two characters.
|
||||
fn number(id: &[u8; 4]) -> Option<usize> {
|
||||
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<Stream>,
|
||||
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<S: ByteSource + ?Sized>(src: &S, at: u64, n: u64) -> Result<Vec<u8>> {
|
||||
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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(src: &S) -> Result<AviFile> {
|
||||
open_with(src, OpenOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_with<S: ByteSource + ?Sized>(src: &S, opts: OpenOptions) -> Result<AviFile> {
|
||||
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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Result<(MainHeader, Vec<Stream>)> {
|
||||
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<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Result<Stream> {
|
||||
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<u64> {
|
||||
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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(&self, src: &S, stream: usize, i: usize) -> Result<Vec<u8>> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
//! 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<u8> {
|
||||
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<T: ByteSource + ?Sized> 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<T: ByteSource + ?Sized> ByteSource for std::sync::Arc<T> {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
use super::*;
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
let mut d = ltype.to_vec();
|
||||
d.extend_from_slice(body);
|
||||
chunk(b"LIST", &d)
|
||||
}
|
||||
|
||||
fn strl(video: bool) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<u8>, 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` <size>, what `riff` holds so far, `LIST` <size>
|
||||
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<u8>, [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 <size> 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::<Vec<_>>(), f.streams[0].chunks.iter().map(|c| c.offset).collect::<Vec<_>>());
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
//! 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<PathBuf> {
|
||||
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::<u64>().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(std::sync::Mutex::new(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::sync::Mutex<std::fs::File>);
|
||||
|
||||
impl filmcraft_avi::ByteSource for FileSource {
|
||||
fn len(&self) -> u64 {
|
||||
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).metadata().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
if buf.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
file.read_exact(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_source_reads_exact_offsets_and_reports_eof() {
|
||||
use filmcraft_avi::ByteSource as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
let path = filmcraft_testkit::fixtures::temp_path(&filmcraft_testkit::fixtures_dir("avi").join("read-at-test.bin"));
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
|
||||
file.write_all(b"0123456789abcdef").unwrap();
|
||||
drop(file);
|
||||
let source = FileSource(std::sync::Mutex::new(std::fs::File::open(&path).unwrap()));
|
||||
assert_eq!(source.len(), 16);
|
||||
let mut bytes = [0; 4];
|
||||
source.read_at(7, &mut bytes).unwrap();
|
||||
assert_eq!(&bytes, b"789a");
|
||||
source.read_at(1, &mut bytes).unwrap();
|
||||
assert_eq!(&bytes, b"1234");
|
||||
assert_eq!(source.read_at(15, &mut bytes).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
assert!(source.read_at(u64::MAX, &mut bytes).is_err());
|
||||
source.read_at(u64::MAX, &mut []).unwrap();
|
||||
std::thread::scope(|scope| {
|
||||
for (offset, expected) in [(0, b"0123"), (8, b"89ab")] {
|
||||
let source = &source;
|
||||
scope.spawn(move || {
|
||||
for _ in 0..64 {
|
||||
let mut bytes = [0; 4];
|
||||
source.read_at(offset, &mut bytes).unwrap();
|
||||
assert_eq!(&bytes, expected);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
drop(source);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
|
@ -117,8 +117,8 @@ impl<'a> BitReader<'a> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Raw Exp-Golomb codeNum, up to 32 leading zeros (so below 2^33).
|
||||
fn read_code_num(&mut self) -> Result<u64> {
|
||||
/// Unsigned Exp-Golomb `ue(v)`.
|
||||
pub fn read_ue(&mut self) -> Result<u32> {
|
||||
let mut zeros = 0u32;
|
||||
while !self.read_bit()? {
|
||||
zeros += 1;
|
||||
|
|
@ -130,20 +130,14 @@ impl<'a> BitReader<'a> {
|
|||
return Ok(0);
|
||||
}
|
||||
let v = self.read_bits_u64(zeros)?;
|
||||
Ok((1u64 << zeros) - 1 + v)
|
||||
}
|
||||
|
||||
/// Unsigned Exp-Golomb `ue(v)`.
|
||||
pub fn read_ue(&mut self) -> Result<u32> {
|
||||
u32::try_from(self.read_code_num()?).map_err(|_| BitError::InvalidExpGolomb)
|
||||
let r = (1u64 << zeros) - 1 + v;
|
||||
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Signed Exp-Golomb `se(v)`.
|
||||
pub fn read_se(&mut self) -> Result<i32> {
|
||||
// codeNum 2^32 is -2^31, so the mapping is done before narrowing to 32 bits.
|
||||
let k = self.read_code_num()? as i64;
|
||||
let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
|
||||
i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
|
||||
let k = self.read_ue()? as i64;
|
||||
Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) })
|
||||
}
|
||||
|
||||
/// Truncated Exp-Golomb `te(v)` with range `max`.
|
||||
|
|
@ -202,11 +196,7 @@ impl BitWriter {
|
|||
self.write_bits(b as u32, 1);
|
||||
}
|
||||
pub fn write_ue(&mut self, v: u32) {
|
||||
self.write_code_num(u64::from(v));
|
||||
}
|
||||
/// Exp-Golomb codeNum up to 2^32 (the code of `se(v)` -2^31).
|
||||
fn write_code_num(&mut self, v: u64) {
|
||||
let x = v + 1;
|
||||
let x = v as u64 + 1;
|
||||
let len = 64 - x.leading_zeros();
|
||||
let zeros = len - 1;
|
||||
for _ in 0..zeros {
|
||||
|
|
@ -220,8 +210,8 @@ impl BitWriter {
|
|||
}
|
||||
}
|
||||
pub fn write_se(&mut self, v: i32) {
|
||||
let m = u64::from(v.unsigned_abs()) * 2;
|
||||
self.write_code_num(if v > 0 { m - 1 } else { m });
|
||||
let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 };
|
||||
self.write_ue(k);
|
||||
}
|
||||
pub fn bit_len(&self) -> usize {
|
||||
self.buf.len() * 8 + self.nbits as usize
|
||||
|
|
@ -387,41 +377,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_exp_golomb_extremes_roundtrip() {
|
||||
let vals = [0, 1, -1, i32::MAX, i32::MIN];
|
||||
let mut w = BitWriter::new();
|
||||
for v in vals {
|
||||
w.write_se(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in vals {
|
||||
assert_eq!(r.read_se(), Ok(v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_exp_golomb_reads_minimum_from_spec_bits() {
|
||||
// 32 zero bits, 1, then the 32-bit suffix 1: codeNum = 2^32 -> -2^31.
|
||||
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
|
||||
assert_eq!(BitReader::new(&bytes).read_se(), Ok(i32::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_exp_golomb_rejects_positive_overflow() {
|
||||
// 32 zero bits, 1, then 32 zero bits: codeNum = 2^32 - 1 -> +2^31, not an i32.
|
||||
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0];
|
||||
assert_eq!(BitReader::new(&bytes).read_se(), Err(BitError::InvalidExpGolomb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_exp_golomb_keeps_u32_range() {
|
||||
// codeNum = 2^32 is a valid se(v) code but does not fit ue(v)'s u32.
|
||||
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
|
||||
assert_eq!(BitReader::new(&bytes).read_ue(), Err(BitError::InvalidExpGolomb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulation_prevention() {
|
||||
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
|
||||
|
|
|
|||
|
|
@ -16,34 +16,13 @@ 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 `<i>` / `<u>`, extension blocks for long subtitles, start-of-programme (TCP) subtracted. |
|
||||
| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `<p>` per cue, `<br/>`, styled `<span>`s for `<i>` / `<b>` / `<u>`, `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. |
|
||||
| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `<p>` per cue, `<br/>`, styled `<span>`s for `<i>` / `<b>` / `<u>`, `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. |
|
||||
| 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
|
||||
frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's
|
||||
frame grid (the engine does this on import) and removes overlaps.
|
||||
|
||||
### TTML speaker metadata
|
||||
|
||||
TTML and DFXP exports define each distinct speaker label once in the document head and refer to
|
||||
it with a stable, unique `ttm:agent` ID. Names are XML-escaped full-name text with preserved
|
||||
whitespace; punctuation, Unicode and literal underscores survive a round trip. Agent type is
|
||||
`other` because a caption's speaker label does not say whether it denotes a person or a group.
|
||||
|
||||
On import, agent definitions directly under `head` or `head/metadata` resolve paragraph and timed
|
||||
span speakers. Speaker metadata uses the standard TTML namespace or the April and October 2006
|
||||
TTaf1 draft namespaces. Agent IDs use `xml:id`, except in the April draft, which uses unqualified
|
||||
`id`. Agent definitions from unrelated application metadata are ignored. Recognized `ttm:agent`
|
||||
attributes take precedence over the legacy local-name `agent` fallback. A full name is preferred,
|
||||
otherwise the first nonempty name (or an explicitly empty name) is used. Multiple agent references become one display label, joined with `, ` in
|
||||
reference order; the project does not retain the individual agent identities. Older FilmCraft
|
||||
files without agent definitions remain readable through the previous underscore-to-space
|
||||
fallback. Other agent metadata, including actor relationships, is not retained.
|
||||
|
||||
This mapping follows [W3C TTML1, third edition (2018), §§12.1.5–12.2.1](https://www.w3.org/TR/2018/REC-ttml1-20181108/#metadata-vocabulary-agent).
|
||||
The [April 2006 TTaf1 draft, §§7.2.1 and 12.1.5–12.2.1](https://www.w3.org/TR/2006/WD-ttaf1-dfxp-20060427/)
|
||||
defines its earlier metadata namespace and agent ID form.
|
||||
|
||||
### SCC writer
|
||||
|
||||
Each caption is sent as a pop-on load (RCL, ENM, then per row a preamble address code and tab
|
||||
|
|
|
|||
|
|
@ -49,10 +49,13 @@ impl Overlay {
|
|||
}
|
||||
}
|
||||
|
||||
/// The caption face: the track's font family (Inter when empty or not installed) in SemiBold at `px`.
|
||||
fn caption_style(font: &str, px: f32) -> TextStyle {
|
||||
let family = if font.trim().is_empty() { filmcraft_text::fonts::DEFAULT_FAMILY } else { font.trim() };
|
||||
TextStyle { family: family.into(), style: "SemiBold".into(), size: px, ..Default::default() }
|
||||
/// The caption face: Inter SemiBold at `px`.
|
||||
fn caption_style(px: f32) -> TextStyle {
|
||||
TextStyle { style: "SemiBold".into(), size: px, ..Default::default() }
|
||||
}
|
||||
|
||||
fn measure(text: &str, px: f32) -> f32 {
|
||||
filmcraft_text::measure(text, &caption_style(px))
|
||||
}
|
||||
|
||||
fn lin(c: [u8; 4]) -> [f32; 4] {
|
||||
|
|
@ -61,12 +64,12 @@ fn lin(c: [u8; 4]) -> [f32; 4] {
|
|||
}
|
||||
|
||||
/// Word-wrap one line to `max_w` pixels.
|
||||
fn wrap(line: &str, ts: &TextStyle, max_w: f32) -> Vec<String> {
|
||||
fn wrap(line: &str, px: f32, max_w: f32) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for word in line.split(' ') {
|
||||
let cand = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") };
|
||||
if !cur.is_empty() && filmcraft_text::measure(&cand, ts) > max_w {
|
||||
if !cur.is_empty() && measure(&cand, px) > max_w {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
cur = word.to_string();
|
||||
} else {
|
||||
|
|
@ -112,17 +115,16 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
|
|||
let scale = h as f32 / 1080.0;
|
||||
let px = (style.size * scale).max(4.0);
|
||||
let max_w = w as f32 * 0.9;
|
||||
let ts = caption_style(&style.font, px);
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
// (speaker names are metadata; like Premiere, they are not burned in)
|
||||
for l in c.plain_lines() {
|
||||
lines.extend(wrap(l.trim(), &ts, max_w));
|
||||
lines.extend(wrap(l.trim(), px, max_w));
|
||||
}
|
||||
lines.retain(|l| !l.is_empty());
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(&ts.family, &ts.style).face).metrics(px);
|
||||
let vm = filmcraft_text::fonts::face(filmcraft_text::resolve("Inter", "SemiBold").face).metrics(px);
|
||||
let (asc, desc) = (vm.ascent, vm.descent);
|
||||
let lh = px * style.line_spacing.max(0.8);
|
||||
let pad_x = (px * 0.3).round();
|
||||
|
|
@ -139,7 +141,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
|
|||
.max(0.0)
|
||||
.round();
|
||||
let side = w as f32 * 0.05;
|
||||
let widths: Vec<f32> = lines.iter().map(|l| filmcraft_text::measure(l, &ts)).collect();
|
||||
let widths: Vec<f32> = lines.iter().map(|l| measure(l, px)).collect();
|
||||
let xs: Vec<f32> = widths
|
||||
.iter()
|
||||
.map(|&lw| match align {
|
||||
|
|
@ -174,7 +176,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
|
|||
}
|
||||
}
|
||||
let baseline = lt + (lh - (asc + desc)) / 2.0 + asc;
|
||||
let l = layout(line, &ts, &ParagraphStyle::default());
|
||||
let l = layout(line, &caption_style(px), &ParagraphStyle::default());
|
||||
let mut m = filmcraft_text::Mask { w: ow, h: oh, a: std::mem::take(&mut cover) };
|
||||
render::draw(&l, &render::at(xs[i], baseline.round()), &mut m, (ox, oy));
|
||||
cover = m.a;
|
||||
|
|
@ -303,27 +305,6 @@ mod tests {
|
|||
assert!(o.px.chunks(4).any(|p| p[0] > 0.9 && p[1] < 0.05 && p[3] > 0.9), "red outline pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_font_is_used() {
|
||||
// the style's font used to be ignored (always Inter)
|
||||
let inter = render_caption(&cap("Hello world"), &CaptionStyle::default(), 1920, 1080).unwrap();
|
||||
let serif = CaptionStyle { font: "Noto Serif".into(), ..Default::default() };
|
||||
let serif = render_caption(&cap("Hello world"), &serif, 1920, 1080).unwrap();
|
||||
assert_ne!((inter.w, &inter.px), (serif.w, &serif.px), "Noto Serif renders like Inter");
|
||||
let mono = CaptionStyle { font: "jetbrains mono".into(), ..Default::default() };
|
||||
let mono = render_caption(&cap("Hello world"), &mono, 1920, 1080).unwrap();
|
||||
assert_ne!((inter.w, &inter.px), (mono.w, &mono.px), "JetBrains Mono renders like Inter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_or_empty_font_falls_back_to_inter() {
|
||||
let inter = render_caption(&cap("Hello"), &CaptionStyle::default(), 1280, 720).unwrap();
|
||||
for font in ["", " ", "No Such Font 123"] {
|
||||
let st = CaptionStyle { font: font.into(), ..Default::default() };
|
||||
assert_eq!(render_caption(&cap("Hello"), &st, 1280, 720), Some(inter.clone()), "font {font:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_track_draws_nothing() {
|
||||
let mut t = CaptionTrack::new(TrackId(1), "S".into(), CaptionFormat::Subtitle);
|
||||
|
|
|
|||
|
|
@ -307,8 +307,7 @@ pub fn parse_clock(s: &str) -> Option<Tick> {
|
|||
|
||||
/// Format ticks as `HH:MM:SS<sep>mmm` (rounded to the nearest millisecond).
|
||||
pub fn format_clock(t: Tick, sep: char) -> String {
|
||||
// The rounding addition must not overflow for an input-derived Tick near i64::MAX.
|
||||
let ms = (i128::from(t.0.max(0)) + i128::from(TICKS_PER_MS) / 2) / i128::from(TICKS_PER_MS);
|
||||
let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS;
|
||||
let (h, m, s, f) = (ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000);
|
||||
format!("{h:02}:{m:02}:{s:02}{sep}{f:03}")
|
||||
}
|
||||
|
|
@ -369,8 +368,6 @@ mod tests {
|
|||
assert_eq!(parse_clock("00:61:00,000"), None);
|
||||
assert_eq!(parse_clock("aa:00:00,000"), None);
|
||||
assert_eq!(format_clock(Tick(TICKS_PER_SECOND * 3723 + TICKS_PER_MS * 45), ','), "01:02:03,045");
|
||||
assert!(format_clock(Tick(i64::MAX), ',').ends_with(",077"));
|
||||
assert_eq!(format_clock(Tick(i64::MIN), '.'), "00:00:00.000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@
|
|||
//! - **CDP (SMPTE ST 334-2):** identifier `96 69`, length, frame-rate code, flags, sequence
|
||||
//! counter, a `cc_data` section (`72`, `E0 | cc_count`, cc_count × (marker/valid/type, two
|
||||
//! bytes)), optional time code / service info sections, footer `74`, the counter again and a
|
||||
//! checksum that makes the packet's byte sum 0 mod 256. The ancillary packet then ends with one ST 291 checksum byte
|
||||
//! (low 8 bits of DID + SDID + data count + CDP bytes).
|
||||
//! checksum that makes the packet's byte sum 0 mod 256.
|
||||
//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption
|
||||
//! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608
|
||||
//! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708
|
||||
|
|
@ -162,9 +161,6 @@ pub fn build_cdp(seq: u16, data: &FrameData) -> Vec<u8> {
|
|||
cdp.push(((256 - sum % 256) % 256) as u8);
|
||||
let mut out = vec![0x61, 0x01, cdp.len() as u8];
|
||||
out.extend(cdp);
|
||||
// SMPTE ST 291 ancillary checksum: low 8 bits of the sum of DID, SDID, data count and the user data words.
|
||||
let anc_sum: u32 = out.iter().map(|&b| b as u32).sum();
|
||||
out.push((anc_sum & 0xff) as u8);
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -665,11 +661,7 @@ mod tests {
|
|||
let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] };
|
||||
let b = build_cdp(7, &d);
|
||||
assert_eq!(&b[..3], &[0x61, 0x01, 73]);
|
||||
// prefix (3) + CDP (73) + ancillary checksum byte (1)
|
||||
assert_eq!(b.len(), 77);
|
||||
let sum: u32 = b[..76].iter().map(|&x| x as u32).sum();
|
||||
assert_eq!(b[76], (sum & 0xff) as u8, "ancillary checksum");
|
||||
let cdp = &b[3..76];
|
||||
let cdp = &b[3..];
|
||||
assert_eq!(cdp.len(), 73);
|
||||
assert_eq!(&cdp[..9], &[0x96, 0x69, 73, 0x4f, 0x43, 0, 7, 0x72, 0xf4]);
|
||||
assert_eq!(cdp.iter().map(|&x| x as u32).sum::<u32>() % 256, 0, "checksum");
|
||||
|
|
|
|||
|
|
@ -215,22 +215,15 @@ fn encode_text(text: &str) -> Vec<u8> {
|
|||
if ch == '<'
|
||||
&& let Some(end) = rest.find('>')
|
||||
{
|
||||
let tag = &rest[1..end];
|
||||
// Only a syntactically plausible tag is markup. Comparisons such as
|
||||
// "2 < 3 > 1" and "x < y > z" must not lose their middle text.
|
||||
let name = tag.strip_prefix('/').unwrap_or(tag);
|
||||
let is_tag = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
|
||||
if is_tag {
|
||||
match tag.trim().to_ascii_lowercase().as_str() {
|
||||
"i" => out.push(0x80),
|
||||
"/i" => out.push(0x81),
|
||||
"u" => out.push(0x82),
|
||||
"/u" => out.push(0x83),
|
||||
_ => {}
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
continue;
|
||||
match rest[1..end].trim().to_ascii_lowercase().as_str() {
|
||||
"i" => out.push(0x80),
|
||||
"/i" => out.push(0x81),
|
||||
"u" => out.push(0x82),
|
||||
"/u" => out.push(0x83),
|
||||
_ => {}
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
continue;
|
||||
}
|
||||
if ch == '\n' {
|
||||
out.push(0x8a);
|
||||
|
|
@ -458,22 +451,6 @@ mod tests {
|
|||
assert!(parse(&b[..500]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_angle_brackets_are_not_discarded_as_markup() {
|
||||
let literal = "2 < 3 > 1, x < y > z, and 5 <7> 6";
|
||||
let doc = Document {
|
||||
cues: vec![Cue { start: FrameRate::FPS_25.tick_of(25), end: FrameRate::FPS_25.tick_of(75), text: literal.into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = write(&doc, Some(FrameRate::FPS_25));
|
||||
let decoded = parse(&encoded).expect("valid EBU STL");
|
||||
assert_eq!(decoded.cues.len(), 1);
|
||||
assert_eq!(decoded.cues[0].text, literal);
|
||||
// Recognized styling still becomes STL control bytes.
|
||||
assert_eq!(encode_text("<i>Hi</i>"), vec![0x80, b'H', b'i', 0x81]);
|
||||
assert_eq!(encode_text("<i >Hi</i >"), vec![0x80, b'H', b'i', 0x81]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programme_start_is_subtracted() {
|
||||
let doc = Document {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@
|
|||
//! offsets (`123f`) at `ttp:frameRate` / `ttp:frameRateMultiplier`, which are exact; without
|
||||
//! one, and always for DFXP (for older players), times are clock times with milliseconds.
|
||||
//!
|
||||
//! **Reading** accepts TTML1 2010, the 2006 `ttaf1` drafts and TTML2:
|
||||
//! **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 `par` time
|
||||
//! containers (`body` / `div` begins add up and their ends clip what is inside), timed `<span>`s inside an untimed `<p>`, `<br/>`, whitespace
|
||||
//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested time containers
|
||||
//! (`body` / `div` begins add up), timed `<span>`s inside an untimed `<p>`, `<br/>`, whitespace
|
||||
//! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
||||
use crate::{Cue, Document, Error, Result, TICKS_PER_MS};
|
||||
|
|
@ -33,9 +31,6 @@ const NS_TT: &str = "http://www.w3.org/ns/ttml";
|
|||
const NS_TTP: &str = "http://www.w3.org/ns/ttml#parameter";
|
||||
const NS_TTS: &str = "http://www.w3.org/ns/ttml#styling";
|
||||
const NS_TTM: &str = "http://www.w3.org/ns/ttml#metadata";
|
||||
const NS_TTM_DRAFT: &str = "http://www.w3.org/2006/10/ttaf1#metadata";
|
||||
const NS_TTM_DRAFT_APRIL: &str = "http://www.w3.org/2006/04/ttaf1#metadata";
|
||||
const NS_XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text";
|
||||
const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation";
|
||||
|
||||
|
|
@ -127,10 +122,6 @@ fn is_ncname(s: &str) -> bool {
|
|||
c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || matches!(x, '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
fn cue_id(cue: &Cue, index: usize) -> String {
|
||||
cue.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", index + 1))
|
||||
}
|
||||
|
||||
/// Write `doc` as TTML. `rate` (IMSC1 only) makes times exact frame offsets; `lang` is the
|
||||
/// document language (`xml:lang`).
|
||||
pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str) -> String {
|
||||
|
|
@ -149,38 +140,13 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
|
|||
}
|
||||
}
|
||||
o.push_str(&format!(" xml:lang=\"{}\">\n", esc(if lang.is_empty() { "en" } else { lang })));
|
||||
// Keep agent identifiers distinct from the IDs already emitted for cues, style and region.
|
||||
let mut ids: HashSet<String> = ["s1".into(), "r1".into()].into_iter().collect();
|
||||
ids.extend(doc.cues.iter().enumerate().map(|(i, c)| cue_id(c, i)));
|
||||
let mut speakers = Vec::new();
|
||||
let mut speaker_ids = HashMap::new();
|
||||
for speaker in doc.cues.iter().filter_map(|c| c.speaker.as_deref()) {
|
||||
if speaker_ids.contains_key(speaker) {
|
||||
continue;
|
||||
}
|
||||
let mut id = format!("agent{}", speakers.len() + 1);
|
||||
// Each collision consumes a distinct reserved ID, so this is bounded by ids.len().
|
||||
while ids.contains(&id) {
|
||||
id.push('_');
|
||||
}
|
||||
ids.insert(id.clone());
|
||||
speaker_ids.insert(speaker, id.clone());
|
||||
speakers.push((speaker, id));
|
||||
}
|
||||
o.push_str(" <head>\n");
|
||||
for (speaker, id) in speakers {
|
||||
o.push_str(&format!(
|
||||
" <ttm:agent xml:id=\"{id}\" type=\"other\"><ttm:name type=\"full\" xml:space=\"preserve\">{}</ttm:name></ttm:agent>\n",
|
||||
// XML 1.0 §2.11 normalizes literal CR even with xml:space="preserve".
|
||||
esc(speaker).replace('\r', " ")
|
||||
));
|
||||
}
|
||||
o.push_str(" <styling>\n <style xml:id=\"s1\" tts:color=\"white\" tts:fontFamily=\"proportionalSansSerif\" tts:fontSize=\"100%\" tts:textAlign=\"center\"/>\n </styling>\n");
|
||||
o.push_str(" <layout>\n <region xml:id=\"r1\" tts:origin=\"10% 10%\" tts:extent=\"80% 80%\" tts:displayAlign=\"after\"/>\n </layout>\n");
|
||||
o.push_str(" </head>\n <body region=\"r1\" style=\"s1\">\n <div>\n");
|
||||
for (i, c) in doc.cues.iter().enumerate() {
|
||||
let id = cue_id(c, i);
|
||||
let agent = c.speaker.as_deref().and_then(|s| speaker_ids.get(s)).map(|id| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{id}\"")).unwrap_or_default();
|
||||
let id = c.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", i + 1));
|
||||
let agent = c.speaker.as_deref().map(|s| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{}\"", esc(&agent_id(s)))).unwrap_or_default();
|
||||
o.push_str(&format!(
|
||||
" <p xml:id=\"{}\" begin=\"{}\" end=\"{}\"{agent}>{}</p>\n",
|
||||
esc(&id),
|
||||
|
|
@ -193,6 +159,12 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
|
|||
o
|
||||
}
|
||||
|
||||
/// `ttm:agent` refers to an agent id; we write the speaker name itself (made an NCName).
|
||||
fn agent_id(s: &str) -> String {
|
||||
let v: String = s.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect();
|
||||
if v.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { v } else { format!("_{v}") }
|
||||
}
|
||||
|
||||
fn gcd(a: i64, b: i64) -> i64 {
|
||||
if b == 0 { a.abs().max(1) } else { gcd(b, a % b) }
|
||||
}
|
||||
|
|
@ -231,56 +203,6 @@ fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
|
|||
n.attributes().find(|a| a.name() == local).map(|a| a.value())
|
||||
}
|
||||
|
||||
fn metadata_namespace(namespace: Option<&str>) -> bool {
|
||||
matches!(namespace, Some(NS_TTM) | Some(NS_TTM_DRAFT) | Some(NS_TTM_DRAFT_APRIL))
|
||||
}
|
||||
|
||||
/// Significant TTML agents are children of head or of head's metadata children (TTML1 §12.1.5).
|
||||
fn speaker_names(root: roxmltree::Node) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
let content_namespace = root.tag_name().namespace();
|
||||
let Some(head) = root.children().find(|n| n.is_element() && n.tag_name().name() == "head" && n.tag_name().namespace() == content_namespace) else {
|
||||
return out;
|
||||
};
|
||||
for agent in head.descendants().filter(|n| n.is_element() && n.tag_name().name() == "agent" && metadata_namespace(n.tag_name().namespace())) {
|
||||
if !agent
|
||||
.parent()
|
||||
.is_some_and(|p| p == head || (p.tag_name().name() == "metadata" && p.tag_name().namespace() == content_namespace && p.parent() == Some(head)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let id = if agent.tag_name().namespace() == Some(NS_TTM_DRAFT_APRIL) {
|
||||
// The April 2006 draft defines an unqualified id, before TTML adopted xml:id.
|
||||
agent.attributes().find(|a| a.name() == "id" && a.namespace().is_none()).map(|a| a.value())
|
||||
} else {
|
||||
agent.attribute((NS_XML, "id"))
|
||||
};
|
||||
let Some(id) = id else { continue };
|
||||
let names: Vec<_> = agent
|
||||
.children()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "name" && metadata_namespace(n.tag_name().namespace()))
|
||||
.map(|n| {
|
||||
let kind = n.attributes().find(|a| a.name() == "type" && a.namespace().is_none()).map(|a| a.value());
|
||||
(kind, n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>())
|
||||
})
|
||||
.collect();
|
||||
let name = names.iter().find(|(kind, _)| *kind == Some("full")).or_else(|| names.iter().find(|(_, text)| !text.is_empty())).or_else(|| names.first());
|
||||
if let Some((_, name)) = name {
|
||||
// An explicitly empty full name remains empty, including our own Some("") speakers.
|
||||
out.insert(id.to_string(), name.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn speaker(n: roxmltree::Node, names: &HashMap<String, String>) -> Option<String> {
|
||||
let refs = n.attributes().find(|a| a.name() == "agent" && metadata_namespace(a.namespace())).map(|a| a.value()).or_else(|| n.attribute("agent"));
|
||||
refs.map(|refs| {
|
||||
// The project stores one speaker label; multiple agents become an ordered display label.
|
||||
refs.split_whitespace().map(|id| names.get(id).cloned().unwrap_or_else(|| id.trim_start_matches('_').replace('_', " "))).collect::<Vec<_>>().join(", ")
|
||||
})
|
||||
}
|
||||
|
||||
fn timing(root: roxmltree::Node) -> Result<Timing> {
|
||||
let invalid =
|
||||
|name: &str| Error::Syntax { line: root.document().text_pos_at(root.range().start).row as usize, msg: format!("invalid TTML timing parameter {name}") };
|
||||
|
|
@ -330,10 +252,6 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
|||
let (h, m) = (num(parts.first()?)?, num(parts.get(1)?)?);
|
||||
let sec_part = parts.get(2)?;
|
||||
let (si, sf) = sec_part.split_once('.').unwrap_or((sec_part, ""));
|
||||
let seconds = num(si)?;
|
||||
if m >= 60 || seconds >= 60 {
|
||||
return None;
|
||||
}
|
||||
let mut t = (h * 3600 + m * 60) * tps + decimal_ticks(si, sf, tps)?;
|
||||
if let Some(fp) = parts.get(3) {
|
||||
if parts.len() > 4 {
|
||||
|
|
@ -341,15 +259,13 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
|||
}
|
||||
let (fi, fs) = fp.split_once('.').unwrap_or((fp, ""));
|
||||
let frames = num(fi)?;
|
||||
let _ = tm.base;
|
||||
let sub: i128 = if fs.is_empty() { 0 } else { num(fs)? };
|
||||
if frames >= i128::from(tm.base) || sub >= i128::from(tm.sub_frame_rate) {
|
||||
return None;
|
||||
}
|
||||
// frames at the effective rate; sub-frames divide a frame
|
||||
let ft = tm.rate.tick_of(1).0 as i128;
|
||||
t += frames * ft + sub * ft / tm.sub_frame_rate.max(1) as i128;
|
||||
}
|
||||
return i64::try_from(t).ok().map(Tick);
|
||||
return Some(Tick(t as i64));
|
||||
}
|
||||
let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?;
|
||||
let (v, unit) = s.split_at(unit_start);
|
||||
|
|
@ -365,17 +281,16 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
|||
"f" => {
|
||||
let n: i128 = vi.parse().ok()?;
|
||||
let fd = tm.rate.tick_of(1).0 as i128;
|
||||
let whole = tm.rate.tick_of(i64::try_from(n).ok()?);
|
||||
// tick_of saturates; a saturated count overflowed rather than named a real time
|
||||
if whole == Tick(i64::MAX) {
|
||||
return None;
|
||||
}
|
||||
whole.0 as i128 + decimal_ticks("0", vf, fd)?
|
||||
let whole = tm.rate.tick_of(n as i64).0 as i128;
|
||||
whole + decimal_ticks("0", vf, fd)?
|
||||
}
|
||||
"t" => {
|
||||
let n: i128 = vi.parse().ok()?;
|
||||
n * tps / tm.tick_rate.max(1) as i128
|
||||
}
|
||||
"t" => decimal_ticks(vi, vf, tps)? / tm.tick_rate.max(1) as i128,
|
||||
_ => return None,
|
||||
};
|
||||
i64::try_from(t).ok().map(Tick)
|
||||
Some(Tick(t as i64))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
|
|
@ -486,7 +401,6 @@ pub fn parse(text: &str) -> Result<Document> {
|
|||
return Err(Error::NotFormat("TTML"));
|
||||
}
|
||||
let tm = timing(root)?;
|
||||
let speakers = speaker_names(root);
|
||||
let mut styles = std::collections::HashMap::new();
|
||||
for s in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
|
||||
if let Some(id) = s.attributes().find(|a| a.name() == "id").map(|a| a.value()) {
|
||||
|
|
@ -499,84 +413,44 @@ pub fn parse(text: &str) -> Result<Document> {
|
|||
let Some(body) = root.children().find(|n| n.is_element() && n.tag_name().name() == "body") else {
|
||||
return Ok(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, &speakers, Fmt::default(), &mut doc);
|
||||
walk(body, Tick::ZERO, &tm, &styles, Fmt::default(), &mut doc);
|
||||
doc.cues.sort_by_key(|c| (c.start, c.end));
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// 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<Tick>,
|
||||
}
|
||||
|
||||
/// 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<Tick>,
|
||||
/// 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<Tick>,
|
||||
/// 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<Tick> {
|
||||
/// 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<Tick>, Option<Tick>) {
|
||||
let p = |k: &str, doc: &mut Document| -> Option<Tick> {
|
||||
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.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),
|
||||
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 end = match (own_end, parent.end) {
|
||||
(Some(e), Some(pe)) => Some(e.min(pe)),
|
||||
(e, pe) => e.or(pe),
|
||||
};
|
||||
Times { begin, end, bad }
|
||||
(begin, end)
|
||||
}
|
||||
|
||||
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String, Fmt>, speakers: &HashMap<String, String>, fmt: Fmt, doc: &mut Document) {
|
||||
fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, 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 t = times(c, parent, *tm, doc);
|
||||
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, speakers, f, doc);
|
||||
let (b, _) = times(c, parent, tm, doc);
|
||||
walk(c, b.unwrap_or(parent), tm, styles, f, doc);
|
||||
}
|
||||
"p" => {
|
||||
let t = times(c, parent, *tm, doc);
|
||||
if t.bad {
|
||||
continue;
|
||||
}
|
||||
let (b, e) = (t.begin.unwrap_or(parent.begin), t.end);
|
||||
let cue_speaker = speaker(c, speakers);
|
||||
let (b, e) = times(c, parent, tm, doc);
|
||||
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());
|
||||
let spans: Vec<roxmltree::Node> = 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) => {
|
||||
match (b, e) {
|
||||
(Some(b), 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 {
|
||||
|
|
@ -588,27 +462,20 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String,
|
|||
}
|
||||
let text = clean(&o.s);
|
||||
if e > b && !text.is_empty() {
|
||||
doc.cues.push(Cue { start: b, end: e, text, speaker: cue_speaker, id, ..Default::default() });
|
||||
doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() });
|
||||
}
|
||||
}
|
||||
None => {
|
||||
_ => {
|
||||
// untimed paragraph: its timed spans are the cues
|
||||
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 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 mut o = Out { s: String::new(), pending: false };
|
||||
collect(sp, styles, style_of(sp, styles, f), &mut o);
|
||||
let text = clean(&o.s);
|
||||
if se > sb && !text.is_empty() {
|
||||
doc.cues.push(Cue {
|
||||
start: sb,
|
||||
end: se,
|
||||
text,
|
||||
speaker: speaker(sp, speakers).or_else(|| cue_speaker.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
doc.cues.push(Cue { start: sb, end: se, text, speaker: speaker.clone(), ..Default::default() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -685,51 +552,6 @@ mod tests {
|
|||
assert_eq!(parse_time("1:2", t), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_overflowed_time_expressions_are_ignored() {
|
||||
let normal = tm(FrameRate::FPS_25);
|
||||
for expr in [
|
||||
"999999999999999h",
|
||||
"999999999999999m",
|
||||
"999999999999999s",
|
||||
"999999999999999ms",
|
||||
"999999999999999f",
|
||||
"999999999999999t",
|
||||
"00:60:00",
|
||||
"00:00:60",
|
||||
"00:00:01:25",
|
||||
] {
|
||||
assert_eq!(parse_time(expr, normal), None, "{expr}");
|
||||
}
|
||||
let subframes = Timing { sub_frame_rate: 2, ..normal };
|
||||
assert!(parse_time("00:00:01:12.1", subframes).is_some());
|
||||
assert_eq!(parse_time("00:00:01:12.2", subframes), None);
|
||||
// Bad cues must not wrap to a plausible but unrelated timeline position.
|
||||
let xml =
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="999999999999999s" end="2s">Bad</p><p begin="1s" end="2s">Good</p></div></body></tt>"#;
|
||||
let doc = parse(xml).expect("well-formed TTML");
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].text, "Good");
|
||||
assert!(!doc.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_tick_offsets_keep_exact_caption_times() {
|
||||
for format in [crate::Format::Ttml, crate::Format::Dfxp] {
|
||||
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:tickRate="10"><body><div><p begin="1.5t" end="12.5t">Hello</p></div></body></tt>"#;
|
||||
let doc = crate::parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND * 3 / 20));
|
||||
assert_eq!(doc.cues[0].end, Tick(TICKS_PER_SECOND * 5 / 4));
|
||||
assert!(doc.warnings.is_empty());
|
||||
}
|
||||
let timing = Timing { tick_rate: 3, ..tm(FrameRate::FPS_25) };
|
||||
assert_eq!(parse_time("0.5t", timing), Some(Tick(TICKS_PER_SECOND / 6)));
|
||||
for expr in ["1.xt", "1.1234567890123t", "999999999999999.5t"] {
|
||||
assert_eq!(parse_time(expr, timing), None, "{expr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_styles_spans_and_nesting() {
|
||||
let x = r#"<?xml version="1.0"?>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub fn split_voice(text: &str) -> (Option<String>, String) {
|
|||
Some(_) => return (None, text.to_string()),
|
||||
None => body.to_string(),
|
||||
};
|
||||
if body.contains("<v ") || body.contains("<v.") || body.contains("<v\t") {
|
||||
if body.contains("<v ") || body.contains("<v.") {
|
||||
return (None, text.to_string());
|
||||
}
|
||||
return ((!name.is_empty()).then(|| name.to_string()), body);
|
||||
|
|
@ -177,19 +177,6 @@ mod tests {
|
|||
assert_eq!(split_voice("<vx>hi"), (None, "<vx>hi".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_voices_with_tab_separator() {
|
||||
let text = "<v Ann>Hello <v\tBob>there";
|
||||
assert_eq!(split_voice(text), (None, text.to_string()));
|
||||
let source = format!("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n{text}\n");
|
||||
let doc = parse(&source).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker, None);
|
||||
assert_eq!(doc.cues[0].text, text);
|
||||
assert_eq!(parse(&write(&doc)).unwrap().cues, doc.cues);
|
||||
let closed = "<v Ann>Hi <v\tBob>x</v>";
|
||||
assert_eq!(split_voice(closed), (None, closed.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_signature() {
|
||||
let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap();
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
//! 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#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn div_begin_is_inherited() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div begin="5s"><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untimed_containers_leave_times_unchanged() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(1.0, 2.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_begins_add_up() {
|
||||
both(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div begin="00:00:10.000"><div begin="1s"><p begin="1s" end="2s">deep</p></div><p begin="0s" dur="1s">mid</p></div><p begin="1s" end="2s">top</p></body></tt>"#,
|
||||
&[(6.0, 7.0, "top"), (15.0, 16.0, "mid"), (17.0, 18.0, "deep")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_begin_offsets_timed_spans() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div><p><span begin="1s" end="2s">x</span></p></div></body></tt>"#, &[(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#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="1s"><div begin="10s" end="12s"><p begin="1s" end="5s">cut</p><p begin="3s" end="4s">gone</p></div></body></tt>"#,
|
||||
&[(12.0, 13.0, "cut")],
|
||||
);
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body dur="3s"><div><p begin="1s" end="5s">x</p></div></body></tt>"#, &[(1.0, 3.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_without_an_end_last_until_their_container_ends() {
|
||||
both(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div begin="2s" end="6s"><p begin="1s">open</p></div><div begin="10s" dur="2s"><p>whole</p></div></body></tt>"#,
|
||||
&[(3.0, 6.0, "open"), (10.0, 12.0, "whole")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_and_dur_take_the_earlier() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="5s" dur="2s">x</p></div></body></tt>"#, &[(1.0, 3.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_containers_warn() {
|
||||
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter"><body><div timeContainer="seq"><p begin="1s" end="2s">x</p></div></body></tt>"#;
|
||||
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#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div begin="1s" end="4s"><p begin="1s" end="9s">x</p></div></body></tt>"#;
|
||||
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::<Vec<_>>();
|
||||
assert_eq!(times(&back), times(&doc), "{format:?}");
|
||||
assert_eq!((back.cues[0].start.seconds(), back.cues[0].end.seconds()), (7.0, 9.0), "{format:?}");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue