Merge remote-tracking branch 'upstream/main' into feat/system-appearance
# Conflicts: # crates/ui-egui/src/i18n/es.tsv # crates/ui-egui/src/i18n/ja.tsv # crates/ui-egui/src/icons.rs
This commit is contained in:
commit
9e3c9c9b65
|
|
@ -31,10 +31,10 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
|
@ -64,6 +64,11 @@ jobs:
|
|||
assert m["id"] == "ai.storyteller.filmcraft" and m["command"] == "filmcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
# Both install the app and the CLI/MCP server (#346).
|
||||
import re
|
||||
def programs(m):
|
||||
return {p for c in m["modules"][0]["build-commands"] for p in re.findall(r"/app/bin/([\w.-]+)", c)}
|
||||
assert programs(src) == programs(bundle) == {"filmcraft", "filmcraft-cli"}, f"Flatpak manifests install {programs(src)} vs {programs(bundle)}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
- name: FreeBSD package layout (stub binaries)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
|
|
@ -71,17 +71,16 @@ jobs:
|
|||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
|
|
@ -101,7 +100,7 @@ jobs:
|
|||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
|
|
@ -129,17 +128,16 @@ jobs:
|
|||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
|
|
@ -163,7 +161,7 @@ jobs:
|
|||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
|
|
@ -187,9 +185,8 @@ jobs:
|
|||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
|
|
@ -203,8 +200,8 @@ jobs:
|
|||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
|
|
@ -228,7 +225,7 @@ jobs:
|
|||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
|
|
@ -251,8 +248,8 @@ jobs:
|
|||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
|
|
@ -266,7 +263,7 @@ jobs:
|
|||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/filmcraft-$FILMCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
|
|
@ -284,16 +281,15 @@ jobs:
|
|||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
|
|
@ -318,7 +314,7 @@ jobs:
|
|||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/filmcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$|/share/doc/filmcraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
|
|
@ -334,17 +330,16 @@ jobs:
|
|||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install wasm-bindgen CLI
|
||||
|
|
@ -362,7 +357,7 @@ jobs:
|
|||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
|
|
@ -381,7 +376,7 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ jobs:
|
|||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
|
|
@ -64,7 +64,7 @@ jobs:
|
|||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist
|
||||
|
|
|
|||
|
|
@ -1665,7 +1665,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cpal",
|
||||
|
|
@ -1689,7 +1689,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1699,7 +1699,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -1707,7 +1707,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-apv"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1717,14 +1717,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
|
|
@ -1740,7 +1740,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1751,14 +1751,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
|
|
@ -1771,11 +1771,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
|
|
@ -1792,7 +1792,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
|
|
@ -1826,7 +1826,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1834,7 +1834,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1844,7 +1844,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
|
|
@ -1857,7 +1857,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
|
|
@ -1888,7 +1888,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-apv",
|
||||
|
|
@ -1918,7 +1918,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
|
|
@ -1931,7 +1931,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
|
|
@ -1941,7 +1941,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1949,7 +1949,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -1966,7 +1966,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -1983,7 +1983,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1993,7 +1993,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2003,7 +2003,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2013,7 +2013,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
|
|
@ -2030,7 +2030,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2041,7 +2041,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
|
@ -2049,7 +2049,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -2062,7 +2062,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
|
|
@ -2071,7 +2071,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -2079,28 +2079,28 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-platform"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-codecs",
|
||||
|
|
@ -2126,7 +2126,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
|
|
@ -2140,7 +2140,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2150,7 +2150,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
|
|
@ -2170,7 +2170,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
|
|
@ -2179,7 +2179,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
|
|
@ -2197,14 +2197,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
|
|
@ -2216,7 +2216,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -2224,7 +2224,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"eframe",
|
||||
|
|
@ -2259,7 +2259,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
|
|
@ -2268,7 +2268,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -7258,7 +7258,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
|||
- **Three-point editing.** Mark In and Out in the Source monitor, then insert (`,`) or overwrite (`.`) onto the patched tracks. Lift (`;`) and extract (`'`) take ranges back out.
|
||||
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
|
||||
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
|
||||
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.
|
||||
|
||||
|
|
|
|||
|
|
@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
|||
|
||||
### Interface localization
|
||||
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/<code>.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish covers the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry. Searches match the displayed labels as well as English sources. Japanese and Brazilian Portuguese cover the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved.
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/<code>.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish and Japanese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet). Searches match the displayed labels as well as English sources. Brazilian Portuguese covers the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved.
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ fn stdout_failed(e: std::io::Error) {
|
|||
if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
return;
|
||||
}
|
||||
eprintln!("filmcraft-cli: can't write to stdout: {e}");
|
||||
diag(format_args!("filmcraft-cli: can't write to stdout: {e}"));
|
||||
STDOUT_ERROR.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
|
|
@ -115,13 +115,23 @@ EXIT STATUS
|
|||
0 success · 1 a command failed · 2 usage error
|
||||
";
|
||||
|
||||
/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's
|
||||
/// reader is gone, which would turn a usage error, a failure or a finished export into a crash.
|
||||
fn write_diag(w: &mut dyn std::io::Write, msg: std::fmt::Arguments) {
|
||||
let _ = writeln!(w, "{msg}");
|
||||
}
|
||||
|
||||
fn diag(msg: std::fmt::Arguments) {
|
||||
write_diag(&mut std::io::stderr(), msg);
|
||||
}
|
||||
|
||||
fn usage(msg: impl std::fmt::Display) -> ! {
|
||||
eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.");
|
||||
diag(format_args!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage."));
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ! {
|
||||
eprintln!("filmcraft-cli: {msg}");
|
||||
diag(format_args!("filmcraft-cli: {msg}"));
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -373,11 +383,7 @@ async fn cli() {
|
|||
if let Some(r) = a.opt("--range") {
|
||||
p["range"] = json!(r);
|
||||
}
|
||||
if let (Some(s0), Some(s1)) = (a.opt("--start"), a.opt("--end")) {
|
||||
p["range"] = json!("custom");
|
||||
p["startSeconds"] = parse_value(s0);
|
||||
p["endSeconds"] = parse_value(s1);
|
||||
}
|
||||
set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end"));
|
||||
if let Some(js) = a.opt("--settings") {
|
||||
p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}")));
|
||||
}
|
||||
|
|
@ -399,7 +405,7 @@ async fn cli() {
|
|||
match r {
|
||||
Ok(v) => {
|
||||
print(&a, &v);
|
||||
eprintln!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64());
|
||||
diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
Err(e) => fail(format!("export: {e}")),
|
||||
}
|
||||
|
|
@ -414,11 +420,11 @@ async fn cli() {
|
|||
let out = a.opt("--out").unwrap_or("frame.png");
|
||||
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
|
||||
let t0 = std::time::Instant::now();
|
||||
let Some(img) = s.render_program(scale) else { fail("no sequence") };
|
||||
let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
|
||||
let dt = t0.elapsed();
|
||||
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
|
||||
std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
|
||||
eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0);
|
||||
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
|
||||
}
|
||||
"mcp" => {
|
||||
let server = match a.opt("--bridge") {
|
||||
|
|
@ -454,8 +460,40 @@ fn written_paths(result: &Value, requested: &str) -> Vec<String> {
|
|||
if paths.is_empty() { vec![requested.to_string()] } else { paths }
|
||||
}
|
||||
|
||||
/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale).
|
||||
fn render_at_playhead(s: &Session, scale: f32) -> Result<filmcraft_engine::render::Image, String> {
|
||||
s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one
|
||||
/// instead of the export silently running over the whole sequence.
|
||||
fn set_custom_bounds(p: &mut Value, start: Option<&str>, end: Option<&str>) {
|
||||
if start.is_none() && end.is_none() {
|
||||
return;
|
||||
}
|
||||
p["range"] = json!("custom");
|
||||
if let Some(s0) = start {
|
||||
p["startSeconds"] = parse_value(s0);
|
||||
}
|
||||
if let Some(s1) = end {
|
||||
p["endSeconds"] = parse_value(s1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod format_tests {
|
||||
#[test]
|
||||
fn render_reports_the_real_reason() {
|
||||
let mut s = filmcraft_engine::Session::default();
|
||||
assert!(super::render_at_playhead(&s, 0.5).unwrap_err().contains("sequence"));
|
||||
s.execute("file.newSequence", serde_json::json!({"width": 16, "height": 16})).unwrap();
|
||||
for scale in [0.0, -1.0, f32::NAN] {
|
||||
let e = super::render_at_playhead(&s, scale).unwrap_err();
|
||||
assert!(e.contains("finite and positive"), "{e}");
|
||||
}
|
||||
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8);
|
||||
}
|
||||
|
||||
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
|
||||
/// decoders at start-up.
|
||||
#[test]
|
||||
|
|
@ -467,6 +505,23 @@ mod format_tests {
|
|||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_diagnostic_stream_does_not_panic() {
|
||||
struct Closed;
|
||||
impl std::io::Write for Closed {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<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;
|
||||
|
|
@ -478,6 +533,23 @@ mod format_tests {
|
|||
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_lone_bound_is_forwarded_as_custom_range() {
|
||||
use serde_json::json;
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, Some("0.25"), None);
|
||||
assert_eq!(p["range"], "custom");
|
||||
assert_eq!(p["startSeconds"], 0.25);
|
||||
assert!(p.get("endSeconds").is_none());
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, None, Some("0.25"));
|
||||
assert_eq!(p["range"], "custom");
|
||||
assert_eq!(p["endSeconds"], 0.25);
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, None, None);
|
||||
assert!(p.get("range").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_format_from_extension() {
|
||||
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
|
||||
|
|
|
|||
|
|
@ -2,49 +2,49 @@
|
|||
"schema": 1,
|
||||
"repo": "storytold/filmcraft",
|
||||
"branch": "main",
|
||||
"head": "a4536bcfedf588cbef9ea24df149c70bf99a037d",
|
||||
"head_date": "2026-10-07T21:56:33Z",
|
||||
"commits": 425,
|
||||
"head": "7b6c134472287db4367fca80bcb498240ba36567",
|
||||
"head_date": "2026-10-09T15:02:38Z",
|
||||
"commits": 539,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 39,
|
||||
"commits": 312,
|
||||
"lines_added": 309158,
|
||||
"lines_deleted": 12778,
|
||||
"prs": 48,
|
||||
"commits": 337,
|
||||
"lines_added": 311167,
|
||||
"lines_deleted": 13200,
|
||||
"binary_added": 74,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-09-30T09:22:00Z",
|
||||
"last_commit": "2026-10-07T21:56:33Z"
|
||||
"last_commit": "2026-10-09T15:02:38Z"
|
||||
},
|
||||
{
|
||||
"login": "storybored379",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 11,
|
||||
"commits": 13,
|
||||
"lines_added": 1501,
|
||||
"lines_deleted": 87,
|
||||
"prs": 15,
|
||||
"commits": 17,
|
||||
"lines_added": 1960,
|
||||
"lines_deleted": 179,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-03T16:31:42Z",
|
||||
"last_commit": "2026-10-05T04:48:25Z"
|
||||
"last_commit": "2026-10-09T04:35:58Z"
|
||||
},
|
||||
{
|
||||
"login": "justjohn12345",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 13,
|
||||
"commits": 35,
|
||||
"lines_added": 8315,
|
||||
"lines_deleted": 839,
|
||||
"prs": 15,
|
||||
"commits": 37,
|
||||
"lines_added": 9087,
|
||||
"lines_deleted": 938,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T19:57:28Z",
|
||||
"last_commit": "2026-10-07T17:24:43Z"
|
||||
"last_commit": "2026-10-08T17:06:13Z"
|
||||
},
|
||||
{
|
||||
"login": "punkhop",
|
||||
|
|
@ -63,14 +63,14 @@
|
|||
"login": "midasdf",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 480,
|
||||
"lines_deleted": 24,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 618,
|
||||
"lines_deleted": 32,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T12:41:17Z",
|
||||
"last_commit": "2026-10-07T21:13:41Z"
|
||||
"last_commit": "2026-10-08T15:40:01Z"
|
||||
},
|
||||
{
|
||||
"login": "EthanSuquet",
|
||||
|
|
@ -89,14 +89,14 @@
|
|||
"login": "dcarnelutti",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 3815,
|
||||
"lines_deleted": 229,
|
||||
"prs": 7,
|
||||
"commits": 7,
|
||||
"lines_added": 4069,
|
||||
"lines_deleted": 240,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T21:04:48Z",
|
||||
"last_commit": "2026-10-07T20:00:08Z"
|
||||
"last_commit": "2026-10-08T16:17:58Z"
|
||||
},
|
||||
{
|
||||
"login": "mweinbach",
|
||||
|
|
@ -154,40 +154,40 @@
|
|||
"login": "carlitosteso3",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 9,
|
||||
"lines_added": 1196,
|
||||
"lines_deleted": 141,
|
||||
"prs": 15,
|
||||
"commits": 15,
|
||||
"lines_added": 1740,
|
||||
"lines_deleted": 207,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:06:02Z",
|
||||
"last_commit": "2026-10-07T17:37:50Z"
|
||||
"last_commit": "2026-10-09T14:21:09Z"
|
||||
},
|
||||
{
|
||||
"login": "SaymonMaster14",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 6,
|
||||
"lines_added": 5514,
|
||||
"lines_deleted": 296,
|
||||
"prs": 8,
|
||||
"commits": 14,
|
||||
"lines_added": 10039,
|
||||
"lines_deleted": 651,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T06:59:57Z",
|
||||
"last_commit": "2026-10-07T18:42:38Z"
|
||||
"last_commit": "2026-10-09T07:32:43Z"
|
||||
},
|
||||
{
|
||||
"login": "Luna161",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 65,
|
||||
"lines_deleted": 1,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 195,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T18:20:23Z",
|
||||
"last_commit": "2026-10-07T20:21:49Z"
|
||||
"last_commit": "2026-10-08T00:35:58Z"
|
||||
},
|
||||
{
|
||||
"login": "RicSchonfelder",
|
||||
|
|
@ -214,6 +214,305 @@
|
|||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:35:41Z",
|
||||
"last_commit": "2026-10-07T20:35:41Z"
|
||||
},
|
||||
{
|
||||
"login": "cirocueto2013-wq",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 1127,
|
||||
"lines_deleted": 226,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:56:58Z",
|
||||
"last_commit": "2026-10-07T23:50:33Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 209,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:57:38Z",
|
||||
"last_commit": "2026-10-08T23:52:30Z"
|
||||
},
|
||||
{
|
||||
"login": "txiki739",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 8,
|
||||
"lines_added": 7976,
|
||||
"lines_deleted": 3333,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:41:48Z",
|
||||
"last_commit": "2026-10-09T09:28:24Z"
|
||||
},
|
||||
{
|
||||
"login": "wstock",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 288,
|
||||
"lines_deleted": 24,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T07:45:37Z",
|
||||
"last_commit": "2026-10-08T12:14:46Z"
|
||||
},
|
||||
{
|
||||
"login": "WavJaby",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 6,
|
||||
"commits": 6,
|
||||
"lines_added": 1090,
|
||||
"lines_deleted": 110,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T08:18:07Z",
|
||||
"last_commit": "2026-10-09T05:47:06Z"
|
||||
},
|
||||
{
|
||||
"login": "MrCloudy2",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 132,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T08:53:54Z",
|
||||
"last_commit": "2026-10-08T08:53:54Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 629,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T09:25:54Z",
|
||||
"last_commit": "2026-10-08T09:37:02Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 2,
|
||||
"lines_added": 96,
|
||||
"lines_deleted": 96,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:12:49Z",
|
||||
"last_commit": "2026-10-08T14:01:39Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 260,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:12:32Z",
|
||||
"last_commit": "2026-10-08T13:12:32Z"
|
||||
},
|
||||
{
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 74,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:44:56Z",
|
||||
"last_commit": "2026-10-09T08:45:28Z"
|
||||
},
|
||||
{
|
||||
"login": "Tom1tk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 47,
|
||||
"lines_deleted": 26,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:47:40Z",
|
||||
"last_commit": "2026-10-08T14:56:39Z"
|
||||
},
|
||||
{
|
||||
"login": "Pathos0925",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 68,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T17:09:15Z",
|
||||
"last_commit": "2026-10-08T17:09:15Z"
|
||||
},
|
||||
{
|
||||
"login": "mjohnson7cd",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 1,
|
||||
"lines_added": 100,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T20:36:28Z",
|
||||
"last_commit": "2026-10-08T20:36:28Z"
|
||||
},
|
||||
{
|
||||
"login": "Oldrich333",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 119,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T20:58:16Z",
|
||||
"last_commit": "2026-10-08T20:58:16Z"
|
||||
},
|
||||
{
|
||||
"login": "cshapiro",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 5,
|
||||
"lines_added": 3128,
|
||||
"lines_deleted": 111,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T23:28:32Z",
|
||||
"last_commit": "2026-10-09T02:57:30Z"
|
||||
},
|
||||
{
|
||||
"login": "rossaai",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 3,
|
||||
"lines_added": 112,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T00:57:43Z",
|
||||
"last_commit": "2026-10-09T00:57:43Z"
|
||||
},
|
||||
{
|
||||
"login": "wisegamesyt-droid",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 758,
|
||||
"lines_deleted": 275,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:11:12Z",
|
||||
"last_commit": "2026-10-09T02:33:35Z"
|
||||
},
|
||||
{
|
||||
"login": "JordyUSA",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 4158,
|
||||
"lines_deleted": 283,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:35:57Z",
|
||||
"last_commit": "2026-10-09T02:53:28Z"
|
||||
},
|
||||
{
|
||||
"login": "jerrykimo",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 117,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:50:09Z",
|
||||
"last_commit": "2026-10-09T01:50:09Z"
|
||||
},
|
||||
{
|
||||
"login": "swillunderscore",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 3,
|
||||
"lines_added": 1008,
|
||||
"lines_deleted": 13,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:58:37Z",
|
||||
"last_commit": "2026-10-09T02:19:21Z"
|
||||
},
|
||||
{
|
||||
"login": "IppSec",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 486,
|
||||
"lines_deleted": 54,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T02:29:23Z",
|
||||
"last_commit": "2026-10-09T02:30:49Z"
|
||||
},
|
||||
{
|
||||
"login": "sanyamsingh001",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 323,
|
||||
"lines_deleted": 25,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T04:06:37Z",
|
||||
"last_commit": "2026-10-09T05:44:39Z"
|
||||
},
|
||||
{
|
||||
"login": "ItzBloxy",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 4,
|
||||
"lines_added": 887,
|
||||
"lines_deleted": 167,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T09:27:48Z",
|
||||
"last_commit": "2026-10-09T10:17:08Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
|
|
@ -222,18 +521,45 @@
|
|||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 400,
|
||||
"lines_added": 324452,
|
||||
"lines_deleted": 14207
|
||||
"commits": 474,
|
||||
"lines_added": 346706,
|
||||
"lines_deleted": 19057
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 10,
|
||||
"lines_added": 9328,
|
||||
"lines_deleted": 524
|
||||
"commits": 18,
|
||||
"lines_added": 13935,
|
||||
"lines_deleted": 854
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 11,
|
||||
"lines_added": 2057,
|
||||
"lines_deleted": 361
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Fable",
|
||||
"version": "5.1",
|
||||
"commits": 5,
|
||||
"lines_added": 322,
|
||||
"lines_deleted": 32
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 235,
|
||||
"lines_deleted": 22
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
|
|
@ -243,15 +569,6 @@
|
|||
"commits": 1,
|
||||
"lines_added": 1632,
|
||||
"lines_deleted": 3
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 70,
|
||||
"lines_deleted": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,3 +40,6 @@ sync_github_name = true
|
|||
|
||||
[people.ItzBloxy]
|
||||
display_name = "Bloxy"
|
||||
|
||||
[people.liangforstudy]
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -402,7 +402,7 @@ impl FilmcraftMcp {
|
|||
let t = p.seconds.map_or_else(|| g.playhead(), filmcraft_time::Tick::from_seconds_f64);
|
||||
let (w, h) =
|
||||
g.active_sequence().map(|q| (q.settings.width, q.settings.height)).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
|
||||
let scale = (max as f32 / w.max(h) as f32).min(1.0);
|
||||
let scale = render_scale(max, w, h);
|
||||
let img = g.try_render_program_at(scale, t).map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
|
||||
})
|
||||
|
|
@ -648,10 +648,26 @@ impl ServerHandler for FilmcraftMcp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Render scale that fits the longest side of a `w` x `h` sequence into `max_side` (never upscales; 0 = no limit, as in `png_rgba`).
|
||||
fn render_scale(max_side: u32, w: u32, h: u32) -> f32 {
|
||||
if max_side == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
(max_side as f32 / w.max(h).max(1) as f32).min(1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_scale_zero_means_no_limit() {
|
||||
assert_eq!(render_scale(0, 1920, 1080), 1.0);
|
||||
assert_eq!(render_scale(960, 1920, 1080), 0.5);
|
||||
assert_eq!(render_scale(960, 640, 360), 1.0);
|
||||
assert_eq!(render_scale(960, 0, 0), 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_commands_and_render() {
|
||||
let mut s = Session::default();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use common::*;
|
|||
#[test]
|
||||
#[ignore]
|
||||
fn adhoc_compare() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
let Some(path) = std::env::var_os("AV1_IVF") else { return };
|
||||
let path = std::path::PathBuf::from(path);
|
||||
let pics = decode_all(&path).unwrap();
|
||||
|
|
|
|||
|
|
@ -6,8 +6,27 @@ use filmcraft_av1::{Decoder, Picture};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// ffmpeg with libsvtav1 (fixtures) and libdav1d (reference), or `None` after reporting a skip.
|
||||
pub fn ffmpeg() -> Option<PathBuf> {
|
||||
filmcraft_testkit::ffmpeg_or_skip("av1 oracle")
|
||||
ffmpeg_with(&[("-encoders", "libsvtav1"), ("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// ffmpeg with libdav1d, for checks that decode existing files only.
|
||||
pub fn ffmpeg_dav1d() -> Option<PathBuf> {
|
||||
ffmpeg_with(&[("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail.
|
||||
fn ffmpeg_with(needs: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?;
|
||||
for &(list, lib) in needs {
|
||||
let out = Command::new(&ff).args(["-hide_banner", list]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();
|
||||
if !out.contains(lib) {
|
||||
filmcraft_testkit::skip("av1 oracle", &format!("ffmpeg has no {lib} (see `ffmpeg {list}`)"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(ff)
|
||||
}
|
||||
|
||||
pub fn fixture_dir() -> PathBuf {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const VECTORS: &[&str] =
|
|||
|
||||
#[test]
|
||||
fn conformance_vectors() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
for name in VECTORS {
|
||||
let Some(path) = test_vector(name) else { return };
|
||||
check_file(&ff, name, &path);
|
||||
|
|
@ -21,7 +21,7 @@ fn conformance_vectors() {
|
|||
#[test]
|
||||
#[ignore]
|
||||
fn conformance_vectors_extended() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
let list: Vec<String> = match std::env::var("AV1_VECTORS") {
|
||||
Ok(v) => v.split(',').map(str::to_string).collect(),
|
||||
Err(_) => EXTENDED.iter().map(|s| s.to_string()).collect(),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
//! - **CDP (SMPTE ST 334-2):** identifier `96 69`, length, frame-rate code, flags, sequence
|
||||
//! counter, a `cc_data` section (`72`, `E0 | cc_count`, cc_count × (marker/valid/type, two
|
||||
//! bytes)), optional time code / service info sections, footer `74`, the counter again and a
|
||||
//! checksum that makes the packet's byte sum 0 mod 256.
|
||||
//! checksum that makes the packet's byte sum 0 mod 256. The ancillary packet then ends with one ST 291 checksum byte
|
||||
//! (low 8 bits of DID + SDID + data count + CDP bytes).
|
||||
//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption
|
||||
//! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608
|
||||
//! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708
|
||||
|
|
@ -161,6 +162,9 @@ pub fn build_cdp(seq: u16, data: &FrameData) -> Vec<u8> {
|
|||
cdp.push(((256 - sum % 256) % 256) as u8);
|
||||
let mut out = vec![0x61, 0x01, cdp.len() as u8];
|
||||
out.extend(cdp);
|
||||
// SMPTE ST 291 ancillary checksum: low 8 bits of the sum of DID, SDID, data count and the user data words.
|
||||
let anc_sum: u32 = out.iter().map(|&b| b as u32).sum();
|
||||
out.push((anc_sum & 0xff) as u8);
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -661,7 +665,11 @@ mod tests {
|
|||
let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] };
|
||||
let b = build_cdp(7, &d);
|
||||
assert_eq!(&b[..3], &[0x61, 0x01, 73]);
|
||||
let cdp = &b[3..];
|
||||
// prefix (3) + CDP (73) + ancillary checksum byte (1)
|
||||
assert_eq!(b.len(), 77);
|
||||
let sum: u32 = b[..76].iter().map(|&x| x as u32).sum();
|
||||
assert_eq!(b[76], (sum & 0xff) as u8, "ancillary checksum");
|
||||
let cdp = &b[3..76];
|
||||
assert_eq!(cdp.len(), 73);
|
||||
assert_eq!(&cdp[..9], &[0x96, 0x69, 73, 0x4f, 0x43, 0, 7, 0x72, 0xf4]);
|
||||
assert_eq!(cdp.iter().map(|&x| x as u32).sum::<u32>() % 256, 0, "checksum");
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ pub struct FrameStreamInfo {
|
|||
}
|
||||
|
||||
/// The OBUs of a low-overhead AV1 sample: `(type, payload)`, stopping at the first malformed one.
|
||||
fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
|
||||
pub(crate) fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while let Some(&h) = data.get(pos) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ pub mod mp4;
|
|||
pub mod mpeg;
|
||||
pub mod mxf;
|
||||
pub mod ogg;
|
||||
mod stream_color;
|
||||
pub mod video;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
|
@ -178,4 +179,6 @@ mod audio_timing_tests;
|
|||
#[cfg(test)]
|
||||
mod rounded_pts_tests;
|
||||
#[cfg(test)]
|
||||
mod stream_color_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_color::{ColorInfo, Matrix, Transfer};
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_isobmff::{AvcConfig, CodecConfig, FourCc, HevcConfig, PcmConfig, SampleEntry, VpcConfig};
|
||||
use filmcraft_matroska::{Codec, MkvFile, TrackKind};
|
||||
|
|
@ -24,6 +24,7 @@ use filmcraft_time::{FrameRate, Tick};
|
|||
|
||||
use crate::audio::{PacketDecoder, decode_pcm};
|
||||
use crate::gop::{GopCache, VideoSamples};
|
||||
use crate::stream_color::ColorCodes;
|
||||
use crate::video::VideoDecoder;
|
||||
use crate::{CodecError, make_video_decoder};
|
||||
|
||||
|
|
@ -218,32 +219,24 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option<filmcraft_color::HdrMe
|
|||
})
|
||||
}
|
||||
|
||||
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32) -> (ColorInfo, bool) {
|
||||
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
|
||||
let Some(col) = &v.colour else { return (c, false) };
|
||||
let mut explicit = false;
|
||||
if let Some(m) = col.matrix_coefficients.and_then(|m| Matrix::from_code(m as u8)) {
|
||||
c.matrix = m;
|
||||
explicit = true;
|
||||
}
|
||||
if let Some(t) = col.transfer_characteristics.and_then(|t| Transfer::from_code(t as u8)) {
|
||||
c.transfer = t;
|
||||
explicit = true;
|
||||
}
|
||||
if let Some(p) = col.primaries {
|
||||
c.primaries = match p {
|
||||
9 => Primaries::Bt2020,
|
||||
12 => Primaries::P3D65,
|
||||
5 => Primaries::Bt601_625,
|
||||
6 => Primaries::Bt601_525,
|
||||
_ => Primaries::Bt709,
|
||||
/// Colour of a video track: the `Colour` element wins; what it leaves unspecified, or all of it
|
||||
/// when there is none, comes from the stream's own description (the SPS VUI in an `avcC` /
|
||||
/// `hvcC`, the sequence header in an `av1C`); the rest defaults by frame size. The flag says
|
||||
/// whether `Colour` signals anything that overrides the decoder.
|
||||
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32, stream: Option<ColorCodes>) -> (ColorInfo, bool) {
|
||||
// H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here
|
||||
let code = |c: Option<u64>| c.unwrap_or(2);
|
||||
let container = v.colour.as_ref().map(|col| {
|
||||
let range = match col.range {
|
||||
Some(1) => Some(false),
|
||||
Some(2) => Some(true),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if col.full_range() {
|
||||
c.range = Range::Full;
|
||||
explicit = true;
|
||||
}
|
||||
(c, explicit)
|
||||
ColorCodes::from_wide(code(col.primaries), code(col.transfer_characteristics), code(col.matrix_coefficients), range)
|
||||
});
|
||||
let explicit = container.is_some_and(|c| Matrix::from_code(c.matrix).is_some() || Transfer::from_code(c.transfer).is_some() || c.full_range == Some(true));
|
||||
let sources: Vec<ColorCodes> = [container, stream].into_iter().flatten().collect();
|
||||
(crate::stream_color::resolve(w, h, &sources), explicit)
|
||||
}
|
||||
|
||||
fn codec_label(c: &Codec) -> String {
|
||||
|
|
@ -312,11 +305,11 @@ impl MkvSource {
|
|||
FrameRate::from_f64(1.0 / step)
|
||||
}
|
||||
};
|
||||
let (color, explicit) = color_of(&v, w, h);
|
||||
ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16);
|
||||
let (color, explicit) = color_of(&v, w, h, ventry.as_ref().and_then(|e| crate::stream_color::from_codec_config(&e.codec)));
|
||||
if explicit {
|
||||
explicit_color = Some(color);
|
||||
}
|
||||
ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16);
|
||||
let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9;
|
||||
let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::<u64>() as f64 * 8.0 / secs) as u64);
|
||||
let ((w, h), par) = if rotation % 2 == 1 { ((h, w), (v.pixel_aspect().1, v.pixel_aspect().0)) } else { ((w, h), v.pixel_aspect()) };
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_color::ColorInfo;
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind};
|
||||
use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
|
||||
|
|
@ -104,48 +104,40 @@ fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u
|
|||
})
|
||||
}
|
||||
|
||||
/// Colour of a video sample entry. The `colr` box (`nclx` / `nclc`) wins; what it leaves
|
||||
/// unspecified, or all of it when there is none (ffmpeg writes MP4 without `colr` by default),
|
||||
/// comes from the stream's own description: `vpcC` / `apvC`, or the SPS VUI in `avcC` / `hvcC`
|
||||
/// and the sequence header in `av1C`. The rest defaults by frame size.
|
||||
fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo {
|
||||
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
|
||||
// VP9 and APV carry their colour description in vpcC / apvC (used when there is no colr box).
|
||||
let vpc = match &entry.codec {
|
||||
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
||||
primaries: v.colour_primaries as u16,
|
||||
transfer: v.transfer_characteristics as u16,
|
||||
matrix: v.matrix_coefficients as u16,
|
||||
full_range: v.full_range,
|
||||
}),
|
||||
CodecConfig::Apv(a) if a.color_description_present => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
||||
primaries: a.color_primaries as u16,
|
||||
transfer: a.transfer_characteristics as u16,
|
||||
matrix: a.matrix_coefficients as u16,
|
||||
full_range: a.full_range,
|
||||
}),
|
||||
use crate::stream_color::ColorCodes;
|
||||
use filmcraft_isobmff::ColorInfo as Colr;
|
||||
let colr = match entry.video.as_ref().and_then(|v| v.color.as_ref()) {
|
||||
Some(Colr::Nclx { primaries, transfer, matrix, full_range }) => {
|
||||
Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), Some(*full_range)))
|
||||
}
|
||||
// QuickTime `nclc` has no range flag
|
||||
Some(Colr::Nclc { primaries, transfer, matrix }) => Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), None)),
|
||||
// an ICC profile, or no `colr`: no code points
|
||||
_ => None,
|
||||
};
|
||||
if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) {
|
||||
let (p, t, m, full) = match col {
|
||||
filmcraft_isobmff::ColorInfo::Nclx { primaries, transfer, matrix, full_range } => (*primaries, *transfer, *matrix, *full_range),
|
||||
filmcraft_isobmff::ColorInfo::Nclc { primaries, transfer, matrix } => (*primaries, *transfer, *matrix, false),
|
||||
_ => return c,
|
||||
};
|
||||
if let Some(m) = Matrix::from_code(m as u8) {
|
||||
c.matrix = m;
|
||||
}
|
||||
if let Some(t) = Transfer::from_code(t as u8) {
|
||||
c.transfer = t;
|
||||
}
|
||||
c.primaries = match p {
|
||||
9 => Primaries::Bt2020,
|
||||
12 => Primaries::P3D65,
|
||||
5 => Primaries::Bt601_625,
|
||||
6 => Primaries::Bt601_525,
|
||||
_ => Primaries::Bt709,
|
||||
};
|
||||
if full {
|
||||
c.range = Range::Full;
|
||||
}
|
||||
}
|
||||
c
|
||||
let stream = match &entry.codec {
|
||||
CodecConfig::Vp9(v) => Some(ColorCodes {
|
||||
primaries: v.colour_primaries,
|
||||
transfer: v.transfer_characteristics,
|
||||
matrix: v.matrix_coefficients,
|
||||
full_range: Some(v.full_range),
|
||||
}),
|
||||
CodecConfig::Apv(a) if a.color_description_present => Some(ColorCodes {
|
||||
primaries: a.color_primaries,
|
||||
transfer: a.transfer_characteristics,
|
||||
matrix: a.matrix_coefficients,
|
||||
full_range: Some(a.full_range),
|
||||
}),
|
||||
CodecConfig::Apv(_) => None,
|
||||
codec => crate::stream_color::from_codec_config(codec),
|
||||
};
|
||||
let sources: Vec<ColorCodes> = [colr, stream].into_iter().flatten().collect();
|
||||
crate::stream_color::resolve(w, h, &sources)
|
||||
}
|
||||
|
||||
impl Mp4Source {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
//! The colour description a video stream carries in its own bitstream, for containers that don't
|
||||
//! repeat it: the H.264 / HEVC sequence parameter set's VUI (from `avcC` / `hvcC`) and the AV1
|
||||
//! sequence header's `color_config` (from `av1C`), read with the decoders' own parsers.
|
||||
//!
|
||||
//! ffmpeg writes MP4 without a `colr` box unless asked (`-movflags +write_colr`), so for most
|
||||
//! files the bitstream is the only place their colour is described. [`resolve`] merges it with
|
||||
//! what the container signals, which wins.
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Range, Transfer};
|
||||
use filmcraft_isobmff::{AvcConfig, CodecConfig, HevcConfig};
|
||||
|
||||
use crate::video::primaries_from_code;
|
||||
|
||||
/// ITU-T H.273 `Unspecified`.
|
||||
const UNSPECIFIED: u8 = 2;
|
||||
|
||||
/// Colour code points (ITU-T H.273 / ISO/IEC 23091-2) and range as one place in a file (a
|
||||
/// container colour box, a sequence header) signals them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ColorCodes {
|
||||
pub primaries: u8,
|
||||
pub transfer: u8,
|
||||
pub matrix: u8,
|
||||
/// `None`: this place doesn't say.
|
||||
pub full_range: Option<bool>,
|
||||
}
|
||||
|
||||
impl ColorCodes {
|
||||
/// From wider code points (a `colr` box or a Matroska `Colour` element): a value that doesn't
|
||||
/// fit a byte is no H.273 code point, so it counts as unspecified.
|
||||
pub fn from_wide(primaries: u64, transfer: u64, matrix: u64, full_range: Option<bool>) -> Self {
|
||||
let code = |v: u64| u8::try_from(v).unwrap_or(UNSPECIFIED);
|
||||
Self { primaries: code(primaries), transfer: code(transfer), matrix: code(matrix), full_range }
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour a video codec configuration's bitstream signals: the VUI of the first SPS in an
|
||||
/// `avcC` / `hvcC`, or the `color_config` of the sequence header in an `av1C`. `None` for other
|
||||
/// codecs, an SPS without VUI, and a configuration that is truncated or corrupt.
|
||||
pub fn from_codec_config(codec: &CodecConfig) -> Option<ColorCodes> {
|
||||
match codec {
|
||||
CodecConfig::Avc(c) => avc(c),
|
||||
CodecConfig::Hevc(c) => hevc(c),
|
||||
CodecConfig::Av1(c) => av1(&c.config_obus),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn avc(c: &AvcConfig) -> Option<ColorCodes> {
|
||||
// NAL header byte: nal_unit_type 7 is an SPS
|
||||
let nal = c.sps.iter().find(|n| n.first().is_some_and(|h| h & 0x1f == 7))?;
|
||||
let sps = filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(1..)?)).ok()?;
|
||||
let v = sps.vui?;
|
||||
Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) })
|
||||
}
|
||||
|
||||
fn hevc(c: &HevcConfig) -> Option<ColorCodes> {
|
||||
// two-byte NAL header: nal_unit_type 33 is an SPS (whatever array the record files it under)
|
||||
let nal = c.arrays.iter().flat_map(|a| &a.nalus).find(|n| n.first().is_some_and(|h| (h >> 1) & 0x3f == 33))?;
|
||||
let sps = filmcraft_hevc::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(2..)?)).ok()?;
|
||||
let v = sps.vui?;
|
||||
Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) })
|
||||
}
|
||||
|
||||
fn av1(config_obus: &[u8]) -> Option<ColorCodes> {
|
||||
// OBU type 1: the sequence header
|
||||
let (_, payload) = crate::hw_frame::obus(config_obus).into_iter().find(|(t, _)| *t == 1)?;
|
||||
let c = filmcraft_av1::SequenceHeader::parse(payload).ok()?.color;
|
||||
Some(ColorCodes { primaries: c.color_primaries, transfer: c.transfer_characteristics, matrix: c.matrix_coefficients, full_range: Some(c.color_range) })
|
||||
}
|
||||
|
||||
/// Colour from the code points a file signals, most authoritative first (the container's colour
|
||||
/// box, then the bitstream's): the matrix, transfer, primaries and range each come from the first
|
||||
/// source that signals a value we know. What no source signals keeps the defaults: the matrix by
|
||||
/// frame size ([`filmcraft_frame::default_matrix`]: BT.601 for SD, BT.709 otherwise), BT.709
|
||||
/// transfer and primaries, limited range.
|
||||
pub fn resolve(width: u32, height: u32, sources: &[ColorCodes]) -> ColorInfo {
|
||||
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..ColorInfo::REC709 };
|
||||
if let Some(m) = sources.iter().find_map(|s| Matrix::from_code(s.matrix)) {
|
||||
c.matrix = m;
|
||||
}
|
||||
if let Some(t) = sources.iter().find_map(|s| Transfer::from_code(s.transfer)) {
|
||||
c.transfer = t;
|
||||
}
|
||||
if let Some(p) = sources.iter().find_map(|s| primaries_from_code(s.primaries)) {
|
||||
c.primaries = p;
|
||||
}
|
||||
if sources.iter().find_map(|s| s.full_range) == Some(true) {
|
||||
c.range = Range::Full;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
//! Colour from the bitstream when the container has none (`crate::stream_color`): ffmpeg writes MP4
|
||||
//! without a `colr` box by default, and an HEVC PQ file imported as Rec. 709 SDR, a full-range
|
||||
//! H.264 file as limited range, and a BT.601 SD file with BT.709 primaries. The parameter sets
|
||||
//! are built here bit by bit; no media files.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_bitstream::{BitWriter, escape_rbsp};
|
||||
use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_isobmff::{Av1Config, AvcConfig, Brand, CodecConfig, HevcConfig, HevcNalArray, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
||||
use filmcraft_media::MediaSource;
|
||||
|
||||
use crate::stream_color::{ColorCodes, from_codec_config, resolve};
|
||||
|
||||
/// The video signal description of a VUI: (colour_primaries, transfer_characteristics,
|
||||
/// matrix_coefficients) when `colour_description_present_flag` is set, and the range flag.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Signal {
|
||||
codes: Option<(u8, u8, u8)>,
|
||||
full_range: bool,
|
||||
}
|
||||
|
||||
const PQ_2020: Signal = Signal { codes: Some((9, 16, 9)), full_range: false };
|
||||
const SMPTE_170M: Signal = Signal { codes: Some((6, 6, 6)), full_range: false };
|
||||
const FULL_ONLY: Signal = Signal { codes: None, full_range: true };
|
||||
|
||||
fn signal(w: &mut BitWriter, s: Signal) {
|
||||
w.write_bit(true); // video_signal_type_present_flag
|
||||
w.write_bits(5, 3); // video_format: unspecified
|
||||
w.write_bit(s.full_range);
|
||||
w.write_bit(s.codes.is_some()); // colour_description_present_flag
|
||||
if let Some((p, t, m)) = s.codes {
|
||||
w.write_bits(p.into(), 8);
|
||||
w.write_bits(t.into(), 8);
|
||||
w.write_bits(m.into(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Baseline H.264 SPS NAL unit (ITU-T H.264 §7.3.2.1.1, VUI per Annex E.1.1) of
|
||||
/// `mbs_w`×`mbs_h` macroblocks, with a VUI carrying `vui` (no VUI when `None`).
|
||||
fn h264_sps(mbs_w: u32, mbs_h: u32, vui: Option<Signal>) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(66, 8); // profile_idc: Baseline
|
||||
w.write_bits(0, 8); // constraint flags
|
||||
w.write_bits(30, 8); // level_idc
|
||||
w.write_ue(0); // seq_parameter_set_id
|
||||
w.write_ue(0); // log2_max_frame_num_minus4
|
||||
w.write_ue(2); // pic_order_cnt_type
|
||||
w.write_ue(1); // max_num_ref_frames
|
||||
w.write_bit(false); // gaps_in_frame_num_value_allowed_flag
|
||||
w.write_ue(mbs_w - 1);
|
||||
w.write_ue(mbs_h - 1);
|
||||
w.write_bits(0b110, 3); // frame_mbs_only, direct_8x8_inference, no cropping
|
||||
w.write_bit(vui.is_some()); // vui_parameters_present_flag
|
||||
if let Some(s) = vui {
|
||||
w.write_bit(false); // aspect_ratio_info_present_flag
|
||||
w.write_bit(false); // overscan_info_present_flag
|
||||
signal(&mut w, s);
|
||||
w.write_bit(false); // chroma_loc_info_present_flag
|
||||
w.write_bit(false); // timing_info_present_flag
|
||||
w.write_bit(false); // nal_hrd_parameters_present_flag
|
||||
w.write_bit(false); // vcl_hrd_parameters_present_flag
|
||||
w.write_bit(false); // pic_struct_present_flag
|
||||
w.write_bit(false); // bitstream_restriction_flag
|
||||
}
|
||||
w.rbsp_trailing();
|
||||
let mut nal = vec![0x67];
|
||||
nal.extend(escape_rbsp(&w.finish()));
|
||||
nal
|
||||
}
|
||||
|
||||
/// A Main 10 HEVC SPS NAL unit (ITU-T H.265 §7.3.2.2, VUI per Annex E.2.1) of `width`×`height`
|
||||
/// (multiples of 8), with a VUI carrying `vui` (no VUI when `None`).
|
||||
fn hevc_sps(width: u32, height: u32, vui: Option<Signal>) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(0, 4); // sps_video_parameter_set_id
|
||||
w.write_bits(0, 3); // sps_max_sub_layers_minus1
|
||||
w.write_bit(true); // sps_temporal_id_nesting_flag
|
||||
// profile_tier_level: Main 10, level 4.1
|
||||
w.write_bits(0, 2);
|
||||
w.write_bit(false);
|
||||
w.write_bits(2, 5);
|
||||
w.write_bits(0x2000_0000, 32);
|
||||
w.write_bits(0b1001, 4);
|
||||
w.write_bits(0, 32);
|
||||
w.write_bits(0, 12);
|
||||
w.write_bits(123, 8);
|
||||
w.write_ue(0); // sps_seq_parameter_set_id
|
||||
w.write_ue(1); // chroma_format_idc 4:2:0
|
||||
w.write_ue(width);
|
||||
w.write_ue(height);
|
||||
w.write_bit(false); // conformance_window_flag
|
||||
w.write_ue(2); // bit_depth_luma_minus8
|
||||
w.write_ue(2); // bit_depth_chroma_minus8
|
||||
w.write_ue(4); // log2_max_pic_order_cnt_lsb_minus4
|
||||
w.write_bit(true); // sps_sub_layer_ordering_info_present_flag
|
||||
w.write_ue(4); // sps_max_dec_pic_buffering_minus1
|
||||
w.write_ue(0); // sps_max_num_reorder_pics
|
||||
w.write_ue(0); // sps_max_latency_increase_plus1
|
||||
w.write_ue(0); // log2_min_luma_coding_block_size_minus3 (8)
|
||||
w.write_ue(1); // log2_diff_max_min_luma_coding_block_size (CTB 16)
|
||||
w.write_ue(0); // log2_min_luma_transform_block_size_minus2 (4)
|
||||
w.write_ue(2); // log2_diff_max_min_luma_transform_block_size (16)
|
||||
w.write_ue(0); // max_transform_hierarchy_depth_inter
|
||||
w.write_ue(0); // max_transform_hierarchy_depth_intra
|
||||
w.write_bit(false); // scaling_list_enabled_flag
|
||||
w.write_bit(false); // amp_enabled_flag
|
||||
w.write_bit(false); // sample_adaptive_offset_enabled_flag
|
||||
w.write_bit(false); // pcm_enabled_flag
|
||||
w.write_ue(0); // num_short_term_ref_pic_sets
|
||||
w.write_bit(false); // long_term_ref_pics_present_flag
|
||||
w.write_bit(false); // sps_temporal_mvp_enabled_flag
|
||||
w.write_bit(false); // strong_intra_smoothing_enabled_flag
|
||||
w.write_bit(vui.is_some()); // vui_parameters_present_flag
|
||||
if let Some(s) = vui {
|
||||
w.write_bit(false); // aspect_ratio_info_present_flag
|
||||
w.write_bit(false); // overscan_info_present_flag
|
||||
signal(&mut w, s);
|
||||
w.write_bit(false); // chroma_loc_info_present_flag
|
||||
w.write_bit(false); // neutral_chroma_indication_flag
|
||||
w.write_bit(false); // field_seq_flag
|
||||
w.write_bit(false); // frame_field_info_present_flag
|
||||
w.write_bit(false); // default_display_window_flag
|
||||
w.write_bit(false); // vui_timing_info_present_flag
|
||||
w.write_bit(false); // bitstream_restriction_flag
|
||||
}
|
||||
w.write_bit(false); // sps_extension_present_flag
|
||||
w.rbsp_trailing();
|
||||
let mut nal = vec![33 << 1, 1];
|
||||
nal.extend(escape_rbsp(&w.finish()));
|
||||
nal
|
||||
}
|
||||
|
||||
/// An AV1 sequence header OBU (AV1 bitstream spec §5.5; reduced still-picture header, profile 0,
|
||||
/// 10-bit 4:2:0) of `width`×`height` with `color_config` signalling `codes` and `full_range`.
|
||||
fn av1_sequence_header(width: u32, height: u32, codes: Option<(u8, u8, u8)>, full_range: bool) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(0, 3); // seq_profile
|
||||
w.write_bit(true); // still_picture
|
||||
w.write_bit(true); // reduced_still_picture_header
|
||||
w.write_bits(0, 5); // seq_level_idx[0]
|
||||
w.write_bits(15, 4); // frame_width_bits_minus_1
|
||||
w.write_bits(15, 4); // frame_height_bits_minus_1
|
||||
w.write_bits(width - 1, 16);
|
||||
w.write_bits(height - 1, 16);
|
||||
w.write_bits(0, 3); // use_128x128_superblock, enable_filter_intra, enable_intra_edge_filter
|
||||
w.write_bits(0, 3); // enable_superres, enable_cdef, enable_restoration
|
||||
w.write_bit(true); // high_bitdepth
|
||||
w.write_bit(false); // mono_chrome
|
||||
w.write_bit(codes.is_some()); // color_description_present_flag
|
||||
if let Some((p, t, m)) = codes {
|
||||
w.write_bits(p.into(), 8);
|
||||
w.write_bits(t.into(), 8);
|
||||
w.write_bits(m.into(), 8);
|
||||
}
|
||||
w.write_bit(full_range); // color_range
|
||||
w.write_bits(0, 2); // chroma_sample_position
|
||||
w.write_bit(false); // separate_uv_delta_q
|
||||
w.write_bit(false); // film_grain_params_present
|
||||
w.rbsp_trailing();
|
||||
let payload = w.finish();
|
||||
// OBU header: type 1 (OBU_SEQUENCE_HEADER), obu_has_size_field; one-byte leb128 size
|
||||
let mut obu = vec![(1 << 3) | 2, u8::try_from(payload.len()).expect("short payload")];
|
||||
obu.extend(payload);
|
||||
obu
|
||||
}
|
||||
|
||||
fn avc(sps: Vec<u8>) -> CodecConfig {
|
||||
CodecConfig::Avc(AvcConfig::new(vec![sps], vec![vec![0x68, 0xCE, 0x38, 0x80]], 4))
|
||||
}
|
||||
|
||||
fn hevc(sps: Vec<u8>) -> CodecConfig {
|
||||
CodecConfig::Hevc(HevcConfig {
|
||||
general_profile_idc: 2,
|
||||
chroma_format_idc: 1,
|
||||
bit_depth_luma: 10,
|
||||
bit_depth_chroma: 10,
|
||||
length_size: 4,
|
||||
arrays: vec![HevcNalArray { completeness: true, nal_type: 33, nalus: vec![sps] }],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn av1(obus: Vec<u8>) -> CodecConfig {
|
||||
CodecConfig::Av1(Av1Config { high_bitdepth: true, chroma_subsampling_x: true, chroma_subsampling_y: true, config_obus: obus, ..Default::default() })
|
||||
}
|
||||
|
||||
/// A one-sample MP4 with `codec` (the sample is never decoded) and, when given, a `colr` box.
|
||||
fn mp4(codec: CodecConfig, w: u16, h: u16, colr: Option<filmcraft_isobmff::ColorInfo>) -> Arc<[u8]> {
|
||||
let mut entry = match codec {
|
||||
CodecConfig::Avc(c) => SampleEntry::avc(c, w, h),
|
||||
CodecConfig::Hevc(c) => SampleEntry::hevc(c, w, h),
|
||||
other => SampleEntry::video(filmcraft_isobmff::FourCc(*b"av01"), other, w, h),
|
||||
};
|
||||
if let Some(v) = entry.video.as_mut() {
|
||||
v.color = colr;
|
||||
}
|
||||
let mut mux = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer");
|
||||
let t = mux.add_track(TrackConfig::new(entry, 30)).expect("track");
|
||||
mux.write_sample(t, WriteSample { data: &[0, 0, 0, 1, 0x65], duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
|
||||
mux.finish().expect("finish").into_inner().into()
|
||||
}
|
||||
|
||||
fn mp4_color(codec: CodecConfig, w: u16, h: u16, colr: Option<filmcraft_isobmff::ColorInfo>) -> ColorInfo {
|
||||
let src = crate::Mp4Source::open("clip.mp4", mp4(codec, w, h, colr)).expect("open");
|
||||
src.info().video.as_ref().expect("video").color
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_pq_without_colr_is_rec2100_pq() {
|
||||
let c = mp4_color(hevc(hevc_sps(1920, 1080, Some(PQ_2020))), 1920, 1080, None);
|
||||
assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Pq, primaries: Primaries::Bt2020, range: Range::Limited });
|
||||
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
|
||||
assert!(ColorSpace::from_info(&c).is_hdr());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_range_h264_without_colr_is_full_range() {
|
||||
// video_full_range_flag with no colour description: full range, the rest by size
|
||||
let c = mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, None);
|
||||
assert_eq!(c, ColorInfo { range: Range::Full, ..ColorInfo::REC709 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bt601_sd_h264_without_colr_has_bt601_primaries() {
|
||||
let c = mp4_color(avc(h264_sps(45, 30, Some(SMPTE_170M))), 720, 480, None);
|
||||
assert_eq!(c, ColorInfo { matrix: Matrix::Bt601, transfer: Transfer::Bt709, primaries: Primaries::Bt601_525, range: Range::Limited });
|
||||
// PAL (BT.470 BG primaries, BT.601 matrix)
|
||||
let pal = Signal { codes: Some((5, 6, 5)), full_range: false };
|
||||
let c = mp4_color(avc(h264_sps(45, 36, Some(pal))), 720, 576, None);
|
||||
assert_eq!((c.primaries, c.matrix), (Primaries::Bt601_625, Matrix::Bt601));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unspecified_or_missing_vui_keeps_the_size_defaults() {
|
||||
let unspecified = Signal { codes: Some((2, 2, 2)), full_range: false };
|
||||
for vui in [None, Some(unspecified)] {
|
||||
assert_eq!(mp4_color(avc(h264_sps(80, 45, vui)), 1280, 720, None), ColorInfo::REC709);
|
||||
assert_eq!(mp4_color(hevc(hevc_sps(1920, 1080, vui)), 1920, 1080, None), ColorInfo::REC709);
|
||||
// SD: the BT.601 matrix, as before
|
||||
assert_eq!(mp4_color(avc(h264_sps(45, 30, vui)), 720, 480, None), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colr_wins_over_the_bitstream() {
|
||||
use filmcraft_isobmff::ColorInfo::{Nclc, Nclx};
|
||||
let sps = || hevc(hevc_sps(1920, 1080, Some(PQ_2020)));
|
||||
// a BT.709 nclx box beats a PQ VUI, and is what the decoder's frames are tagged with
|
||||
let colr = Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(colr.clone())), ColorInfo::REC709);
|
||||
// HLG in colr, PQ in the VUI: HLG
|
||||
let hlg = Nclx { primaries: 9, transfer: 18, matrix: 9, full_range: false };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(hlg)).transfer, Transfer::Hlg);
|
||||
// limited range in colr beats a full-range VUI
|
||||
assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(colr)).range, Range::Limited);
|
||||
// what colr leaves unspecified comes from the VUI
|
||||
let partial = Nclx { primaries: 2, transfer: 16, matrix: 2, full_range: false };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(partial)), mp4_color(sps(), 1920, 1080, None));
|
||||
// QuickTime nclc has no range flag: the VUI's range stands
|
||||
let nclc = Nclc { primaries: 1, transfer: 1, matrix: 1 };
|
||||
assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(nclc)).range, Range::Full);
|
||||
// an ICC profile has no code points: the bitstream decides
|
||||
let icc = filmcraft_isobmff::ColorInfo::Icc { kind: filmcraft_isobmff::FourCc(*b"prof"), profile: vec![0; 8] };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(icc)).transfer, Transfer::Pq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_sequence_header_colour() {
|
||||
let obu = av1_sequence_header(1920, 1080, Some((9, 16, 9)), false);
|
||||
assert_eq!(from_codec_config(&av1(obu.clone())), Some(ColorCodes { primaries: 9, transfer: 16, matrix: 9, full_range: Some(false) }));
|
||||
let c = mp4_color(av1(obu), 1920, 1080, None);
|
||||
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
|
||||
// no colour description: unspecified code points, the range flag still counts
|
||||
let obu = av1_sequence_header(640, 360, None, true);
|
||||
assert_eq!(from_codec_config(&av1(obu)), Some(ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: Some(true) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matroska_without_colour_uses_the_bitstream() {
|
||||
let CodecConfig::Hevc(hvcc) = hevc(hevc_sps(1920, 1080, Some(PQ_2020))) else { panic!("an hvcC") };
|
||||
let mut spec = TrackSpec::new(TrackKind::Video, "V_MPEGH/ISO/HEVC");
|
||||
spec.codec_private = hvcc.to_bytes();
|
||||
spec.video_size = Some((1920, 1080));
|
||||
spec.default_duration_ns = Some(33_333_333);
|
||||
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).expect("writer");
|
||||
w.write_frame(0, 0, true, &[0, 0, 0, 1, 0x26], None).expect("frame");
|
||||
let b: Arc<[u8]> = w.finish().expect("finish").into_inner().into();
|
||||
let src = crate::MkvSource::open("clip.mkv", b).expect("open");
|
||||
let c = src.info().video.as_ref().expect("video").color;
|
||||
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
|
||||
assert_eq!((c.primaries, c.matrix), (Primaries::Bt2020, Matrix::Bt2020Ncl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_takes_each_field_from_the_first_source_that_knows_it() {
|
||||
let colr = ColorCodes { primaries: 1, transfer: 2, matrix: 0, full_range: None };
|
||||
let vui = ColorCodes { primaries: 9, transfer: 18, matrix: 9, full_range: Some(true) };
|
||||
// matrix 0 (identity) is not one we know: the VUI's BT.2020 is used
|
||||
let c = resolve(1920, 1080, &[colr, vui]);
|
||||
assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Hlg, primaries: Primaries::Bt709, range: Range::Full });
|
||||
assert_eq!(resolve(720, 480, &[]), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 });
|
||||
// code points that don't fit a byte are unspecified, not truncated (0x109 is not 9)
|
||||
assert_eq!(ColorCodes::from_wide(0x109, 0x110, 0x109, None), ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: None });
|
||||
}
|
||||
|
||||
/// Every truncation and many bit flips of each configuration: never a panic, and the result is
|
||||
/// either some colour or none (the size defaults).
|
||||
#[test]
|
||||
fn hostile_parameter_sets_never_panic() {
|
||||
let configs: Vec<(Vec<u8>, fn(Vec<u8>) -> CodecConfig)> = vec![
|
||||
(h264_sps(45, 30, Some(SMPTE_170M)), avc),
|
||||
(hevc_sps(1920, 1080, Some(PQ_2020)), hevc),
|
||||
(av1_sequence_header(1920, 1080, Some((9, 16, 9)), false), av1),
|
||||
];
|
||||
for (good, make) in configs {
|
||||
assert!(from_codec_config(&make(good.clone())).is_some());
|
||||
let mut variants: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
|
||||
for bit in 0..good.len() * 8 {
|
||||
let mut b = good.clone();
|
||||
b[bit / 8] ^= 0x80 >> (bit % 8);
|
||||
variants.push(b);
|
||||
}
|
||||
variants.push(vec![0xff; 64]);
|
||||
variants.push(Vec::new());
|
||||
for v in variants {
|
||||
let r = std::panic::catch_unwind(|| from_codec_config(&make(v.clone())));
|
||||
assert!(r.is_ok(), "panicked on {v:02x?}");
|
||||
}
|
||||
}
|
||||
// configurations with no parameter sets at all
|
||||
assert_eq!(from_codec_config(&CodecConfig::Avc(AvcConfig::default())), None);
|
||||
assert_eq!(from_codec_config(&CodecConfig::Hevc(HevcConfig::default())), None);
|
||||
assert_eq!(from_codec_config(&CodecConfig::Av1(Av1Config::default())), None);
|
||||
// a garbage SPS imports with the size defaults
|
||||
assert_eq!(mp4_color(avc(vec![0x67, 0xff, 0xff]), 1280, 720, None), ColorInfo::REC709);
|
||||
assert_eq!(mp4_color(hevc(vec![33 << 1, 1, 0xff]), 1920, 1080, None), ColorInfo::REC709);
|
||||
}
|
||||
|
|
@ -615,3 +615,32 @@ fn apv_mp4_and_raw_bitstream_decode() {
|
|||
let decoded_mp4 = mp4_src.video_frame(FrameRequest::full(Tick::ZERO)).expect("decode mp4 frame");
|
||||
assert_eq!(decoded_mp4.to_rgba8(), decoded_raw.to_rgba8());
|
||||
}
|
||||
|
||||
/// ffmpeg writes MP4 without a `colr` box unless asked: the colour comes from the x265 / x264
|
||||
/// SPS VUI, and matches the same stream remuxed with `colr`.
|
||||
#[test]
|
||||
fn mp4_colour_comes_from_the_sps_without_colr() {
|
||||
use filmcraft_color::{ColorSpace, Primaries, Range};
|
||||
let src = |name: &str, args: &[&str]| fixture(name, args).map(|b| crate::open_bytes(name, b).expect("open"));
|
||||
let color = |s: &filmcraft_media::SharedSource| s.info().video.as_ref().expect("video").color;
|
||||
let lavfi = |size: &'static str| ["-f", "lavfi", "-i", size, "-t", "0.2"];
|
||||
let x265 = "colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:log-level=error";
|
||||
let mut pq = lavfi("testsrc2=s=640x360:r=25").to_vec();
|
||||
pq.extend(["-c:v", "libx265", "-tag:v", "hvc1", "-pix_fmt", "yuv420p10le", "-x265-params", x265]);
|
||||
let Some(plain) = src("hevc_pq_no_colr.mp4", &pq) else { return };
|
||||
assert_eq!(ColorSpace::from_info(&color(&plain)), ColorSpace::Rec2100Pq);
|
||||
let colr_path = fixture_path("hevc_pq_no_colr.mp4", &pq).expect("fixture");
|
||||
if let Some(with_colr) = src("hevc_pq_colr.mp4", &["-i", colr_path.to_str().expect("path"), "-c", "copy", "-movflags", "+write_colr"]) {
|
||||
assert_eq!(color(&with_colr), color(&plain));
|
||||
}
|
||||
let mut full = lavfi("testsrc2=s=640x360:r=25").to_vec();
|
||||
full.extend(["-c:v", "libx264", "-pix_fmt", "yuvj420p", "-color_range", "pc"]);
|
||||
if let Some(s) = src("h264_full_no_colr.mp4", &full) {
|
||||
assert_eq!(color(&s).range, Range::Full);
|
||||
}
|
||||
let mut sd = lavfi("testsrc2=s=720x480:r=30000/1001").to_vec();
|
||||
sd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", "colorprim=smpte170m:transfer=smpte170m:colormatrix=smpte170m"]);
|
||||
if let Some(s) = src("h264_sd601_no_colr.mp4", &sd) {
|
||||
assert_eq!(color(&s).primaries, Primaries::Bt601_525);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -535,15 +535,30 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt
|
|||
match edge {
|
||||
Edge::In => {
|
||||
// extending left (d<0) needs media before source_in; shortening needs min duration
|
||||
let max_ext = Tick((head(ctx, it).0 as f64 / speed).floor() as i64);
|
||||
// a reversed clip plays source_out down to source_in, so its In edge consumes media after source_out
|
||||
let handle = match (it.reverse, media) {
|
||||
(true, Some(m)) => (m - it.source_out()).max(Tick::ZERO),
|
||||
(true, None) => Tick::MAX,
|
||||
(false, _) => head(ctx, it),
|
||||
};
|
||||
let max_ext = if handle == Tick::MAX { Tick::MAX } else { Tick((handle.0 as f64 / speed).floor() as i64) };
|
||||
let lo = if mode == TrimMode::Regular { (-(it.start - prev_end)).max(-max_ext) } else { -max_ext };
|
||||
let hi = it.duration - ctx.min_duration;
|
||||
d = d.clamp(if it.frame_hold.is_some() { Tick::MIN } else { lo }, hi);
|
||||
// a frame hold needs no source head, but a Regular trim still stops at the previous clip
|
||||
let lo = match (it.frame_hold.is_some(), mode) {
|
||||
(true, TrimMode::Regular) => -(it.start - prev_end),
|
||||
(true, _) => Tick::MIN,
|
||||
(false, _) => lo,
|
||||
};
|
||||
d = d.clamp(lo, hi);
|
||||
}
|
||||
Edge::Out => {
|
||||
let lo = -(it.duration - ctx.min_duration);
|
||||
let mut hi = if mode == TrimMode::Regular { next_start - it.end() } else { Tick::MAX };
|
||||
if let Some(m) = media {
|
||||
if it.reverse && it.frame_hold.is_none() {
|
||||
// a reversed clip's Out edge consumes media before source_in
|
||||
hi = hi.min(Tick((head(ctx, it).0 as f64 / speed).floor() as i64));
|
||||
} else if let Some(m) = media {
|
||||
let remain = Tick(((m - it.source_out()).0 as f64 / speed).floor() as i64);
|
||||
hi = hi.min(remain);
|
||||
}
|
||||
|
|
@ -751,10 +766,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
|||
let mut d = delta;
|
||||
// left out-point limits
|
||||
d = d.max(-(l.duration - ctx.min_duration)).min(r.duration - ctx.min_duration);
|
||||
if let Some(m) = media_len(ctx, l) {
|
||||
// a reversed clip consumes media from the opposite end, so its media limit is the other one
|
||||
if l.reverse {
|
||||
d = d.min(Tick((head(ctx, l).0 as f64 / l.speed.abs()).floor() as i64));
|
||||
} else if let Some(m) = media_len(ctx, l) {
|
||||
d = d.min(Tick(((m - l.source_out()).0 as f64 / l.speed.abs()).floor() as i64));
|
||||
}
|
||||
d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
if r.reverse {
|
||||
if let Some(m) = media_len(ctx, r) {
|
||||
d = d.max(-Tick(((m - r.source_out()).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
}
|
||||
if d == Tick::ZERO {
|
||||
return Ok(d);
|
||||
}
|
||||
|
|
@ -762,15 +786,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
|||
let before = seq.clone();
|
||||
{
|
||||
let (_, l) = seq.find_item_mut(left).ok_or(EditError::NoItem(left))?;
|
||||
if l.reverse {
|
||||
l.source_in -= src_of(d, ls);
|
||||
}
|
||||
l.duration += d;
|
||||
}
|
||||
{
|
||||
let (_, r) = seq.find_item_mut(right).ok_or(EditError::NoItem(right))?;
|
||||
r.start += d;
|
||||
r.duration -= d;
|
||||
r.source_in += src_of(d, rs);
|
||||
if !r.reverse {
|
||||
r.source_in += src_of(d, rs);
|
||||
}
|
||||
}
|
||||
let _ = ls;
|
||||
transitions_follow_cuts(&before, seq);
|
||||
Ok(d)
|
||||
}
|
||||
|
|
@ -799,7 +827,10 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
|||
let mut d = delta;
|
||||
if let Some(p) = &prev {
|
||||
d = d.max(-(p.duration - ctx.min_duration));
|
||||
if let Some(m) = media_len(ctx, p) {
|
||||
if p.reverse {
|
||||
// a reversed neighbour grows by pulling earlier media in front of its source In
|
||||
d = d.min(Tick((head(ctx, p).0 as f64 / p.speed.abs()).floor() as i64));
|
||||
} else if let Some(m) = media_len(ctx, p) {
|
||||
d = d.min(Tick(((m - p.source_out()).0 as f64 / p.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
|
|
@ -807,7 +838,14 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
|||
}
|
||||
if let Some(n) = &next {
|
||||
d = d.min(n.duration - ctx.min_duration);
|
||||
d = d.max(-Tick((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64));
|
||||
if n.reverse {
|
||||
// a reversed neighbour grows leftwards by pulling later media behind its source Out
|
||||
if let Some(m) = media_len(ctx, n) {
|
||||
d = d.max(-Tick(((m - n.source_out()).0 as f64 / n.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
d = d.max(-Tick((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
d = d.min(neighbours(tr, clip).1 - it.end());
|
||||
}
|
||||
|
|
@ -819,12 +857,17 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
|||
if let Some(p) = prev {
|
||||
let pi = t.item_mut(p.id).ok_or(EditError::NoItem(p.id))?;
|
||||
pi.duration += d;
|
||||
if p.reverse {
|
||||
pi.source_in -= src_of(d, p.speed);
|
||||
}
|
||||
}
|
||||
if let Some(n) = next {
|
||||
let ni = t.item_mut(n.id).ok_or(EditError::NoItem(n.id))?;
|
||||
ni.start += d;
|
||||
ni.duration -= d;
|
||||
ni.source_in += src_of(d, n.speed);
|
||||
if !n.reverse {
|
||||
ni.source_in += src_of(d, n.speed);
|
||||
}
|
||||
}
|
||||
let me = t.item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
me.start += d;
|
||||
|
|
|
|||
|
|
@ -234,6 +234,27 @@ fn close_gap_works() {
|
|||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_trim_uses_the_handle_its_playback_direction_consumes() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
// source frames [5, 15) played backwards; the media is 1000 frames long
|
||||
let a = fx.put(v1, 30, 10, 5);
|
||||
fx.seq.find_item_mut(a).unwrap().1.reverse = true;
|
||||
let mut n = fx.next;
|
||||
// out extends into the 5 frames before source_in, not into the 985 after source_out
|
||||
let d = trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, f(20), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, f(5));
|
||||
let it = fx.seq.find_item(a).unwrap().1;
|
||||
assert_eq!((it.source_in, it.source_out()), (f(0), f(15)));
|
||||
// in extends into the media after source_out
|
||||
let d = trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, -f(10), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, -f(10));
|
||||
let it = fx.seq.find_item(a).unwrap().1;
|
||||
assert_eq!((it.source_in, it.source_out()), (f(0), f(25)));
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_trim_respects_neighbours_and_handles() {
|
||||
let mut fx = Fx::new();
|
||||
|
|
@ -253,6 +274,24 @@ fn regular_trim_respects_neighbours_and_handles() {
|
|||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_regular_in_trim_stops_at_previous_clip() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 15, 10, 5);
|
||||
fx.seq.find_item_mut(b).unwrap().1.frame_hold = Some(f(5));
|
||||
let mut n = fx.next;
|
||||
// 5 frames of gap: a 20 frame extension is clamped to 5 instead of overlapping the previous clip
|
||||
let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(20), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, -f(5));
|
||||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 15)]);
|
||||
// no space left: nothing to do, and no error
|
||||
let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, Tick::ZERO);
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ripple_trim_shifts_following() {
|
||||
let mut fx = Fx::new();
|
||||
|
|
@ -359,6 +398,25 @@ fn roll_slip_slide() {
|
|||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_keeps_reversed_neighbours_content() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 30, 20);
|
||||
let b = fx.put(v1, 30, 30, 50);
|
||||
let c = fx.put(v1, 60, 30, 70);
|
||||
for id in [a, c] {
|
||||
fx.seq.find_item_mut(id).unwrap().1.reverse = true;
|
||||
}
|
||||
let mut n = fx.next;
|
||||
slide(&mut fx.seq, b, f(10), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(fx.spans(v1), vec![(0, 40), (40, 30), (70, 20)]);
|
||||
assert_eq!(fx.seq.find_item(a).unwrap().1.source_in, f(10));
|
||||
assert_eq!(fx.seq.find_item(b).unwrap().1.source_in, f(50));
|
||||
assert_eq!(fx.seq.find_item(c).unwrap().1.source_in, f(70));
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_stay_on_their_cuts() {
|
||||
let mut fx = Fx::new();
|
||||
|
|
@ -673,3 +731,21 @@ fn shorter_head_closes_the_stretch_after_the_cut_on_sync_locked_tracks() {
|
|||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10), (20, 10)], "unchanged on failure");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roll_reversed_clips_keeps_media_mapping() {
|
||||
let mut fx = Fx::new();
|
||||
let v = fx.v(0);
|
||||
let mut a = fx.item(0, 30, 20);
|
||||
a.reverse = true;
|
||||
let mut b = fx.item(30, 30, 50);
|
||||
b.reverse = true;
|
||||
let (ia, ib) = (a.id, b.id);
|
||||
fx.seq.track_mut(v).unwrap().items.extend([a, b]);
|
||||
let mut n = 0;
|
||||
roll(&mut fx.seq, ia, ib, f(10), &mut Fx::ctx(&mut n)).unwrap();
|
||||
let (_, l) = fx.seq.find_item(ia).unwrap();
|
||||
assert_eq!((l.start, l.duration, l.source_in), (f(0), f(40), f(10)));
|
||||
let (_, r) = fx.seq.find_item(ib).unwrap();
|
||||
assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,11 @@ pub fn through_edits(seq: &Sequence) -> Vec<ThroughEdit> {
|
|||
/// piece is listed. The left piece absorbs the right one (keeping its own effects and attributes);
|
||||
/// a transition sitting on the cut is removed. Returns the number of edits joined.
|
||||
pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize {
|
||||
join_through_edits_where(seq, |a, b| only.is_empty() || only.contains(&a) || only.contains(&b))
|
||||
}
|
||||
|
||||
/// Join the through edits on unlocked tracks whose (left, right) pieces `wanted` accepts.
|
||||
pub fn join_through_edits_where(seq: &mut Sequence, wanted: impl Fn(ClipId, ClipId) -> bool) -> usize {
|
||||
let mut n = 0;
|
||||
for tr in seq.all_tracks_mut() {
|
||||
if tr.locked {
|
||||
|
|
@ -67,8 +72,7 @@ pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize {
|
|||
let mut i = 0;
|
||||
while i + 1 < tr.items.len() {
|
||||
let (a, b) = (&tr.items[i], &tr.items[i + 1]);
|
||||
let wanted = only.is_empty() || only.contains(&a.id) || only.contains(&b.id);
|
||||
if !(wanted && is_through_edit(a, b)) {
|
||||
if !(wanted(a.id, b.id) && is_through_edit(a, b)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32
|
|||
let mut done = 0usize;
|
||||
while done < frames {
|
||||
let n = chunk.min(frames - done);
|
||||
let b = src.audio(start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let b = src.audio_stream(need.audio_stream, start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
match b.channels.get(c).or(b.channels.first()) {
|
||||
Some(x) => o.extend(x.iter().copied().chain(std::iter::repeat(0.0)).take(n)),
|
||||
|
|
@ -230,8 +230,19 @@ fn ensure_dir(s: &Session, dir: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
fn check_linked_streams(s: &Session, seq: ItemId, plan: &AudioPlan) -> Result<()> {
|
||||
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
|
||||
let needs = audio_needs(&s.project, seq, &NeedOptions { nests: plan.nests, ..Default::default() });
|
||||
if needs.iter().any(|need| need.audio_stream > 0) {
|
||||
return Err(EngineError::Other("linked AAF media cannot select container audio streams; choose embedded or separate audio".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prepare the audio essence of an export; returns the essence and the files written.
|
||||
pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str) -> Result<(Vec<AudioEssence>, Vec<String>)> {
|
||||
check_linked_streams(s, seq, plan)?;
|
||||
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
|
@ -255,7 +266,7 @@ pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str
|
|||
let name = audio_clip(&s.project, seq, c).map(|(_, _, ti)| ti.name.clone()).unwrap_or_default();
|
||||
format!("{} {}", safe_name(&name), c.0)
|
||||
}
|
||||
EssenceKey::Media(_) => safe_name(
|
||||
EssenceKey::Media(_) | EssenceKey::MediaStream { .. } => safe_name(
|
||||
need.path
|
||||
.as_deref()
|
||||
.map(|p| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default())
|
||||
|
|
@ -380,6 +391,7 @@ pub fn export_aaf(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
|
||||
let seq = crate::export_tools::sequence_param(s, p, cmd)?;
|
||||
let plan = plan_from(s, p, seq, cmd, false)?;
|
||||
check_linked_streams(s, seq, &plan)?;
|
||||
let (dir, stem) = split(&path);
|
||||
let media_dir = join(&dir, &format!("{stem} Media"));
|
||||
let mut files = Vec::new();
|
||||
|
|
|
|||
|
|
@ -311,3 +311,192 @@ fn commands_are_registered_with_menus_and_validate_params() {
|
|||
assert!(s.execute("file.importAaf", json!({"path": dir.join("a.mov").to_string_lossy()})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
// Selected-stream essence uses only PR361 schema13 placement and decoder fields.
|
||||
mod selected_streams {
|
||||
use crate::{Services, Session};
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource};
|
||||
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, SequenceSettings};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type Calls = Arc<Mutex<Vec<(usize, i64, usize, u32)>>>;
|
||||
|
||||
struct Tones {
|
||||
info: MediaInfo,
|
||||
calls: Calls,
|
||||
}
|
||||
|
||||
impl Tones {
|
||||
fn new(streams: usize) -> Self {
|
||||
let source = filmcraft_media::generators::GeneratorSource::new(
|
||||
filmcraft_media::Generator::BarsAndTone,
|
||||
32,
|
||||
18,
|
||||
FrameRate::FPS_24,
|
||||
Tick::from_seconds_f64(2.0),
|
||||
);
|
||||
let mut info = source.info().clone();
|
||||
info.kind = MediaKind::AudioOnly;
|
||||
info.video = None;
|
||||
info.audio_streams = vec![info.audio_streams[0].clone(); streams];
|
||||
Self { info, calls: Arc::default() }
|
||||
}
|
||||
|
||||
fn sample(stream: usize, channel: usize, frame: i64, rate: u32) -> f32 {
|
||||
let frequency = (stream * 2 + channel + 1) as f64 * 240.0;
|
||||
let amplitude = 0.05 * (stream + 1) as f64;
|
||||
(amplitude * (std::f64::consts::TAU * frequency * frame as f64 / rate as f64).sin()) as f32
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for Tones {
|
||||
fn info(&self) -> &MediaInfo {
|
||||
&self.info
|
||||
}
|
||||
fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
|
||||
Err(MediaError::NoStream("video"))
|
||||
}
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
self.audio_stream(0, start, frames, sample_rate)
|
||||
}
|
||||
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
if stream >= self.info.audio_streams.len() {
|
||||
return Err(MediaError::NoStream("audio"));
|
||||
}
|
||||
self.calls.lock().unwrap().push((stream, start, frames, sample_rate));
|
||||
Ok(AudioBuffer {
|
||||
sample_rate,
|
||||
channels: (0..2).map(|channel| (0..frames).map(|i| Self::sample(stream, channel, start + i as i64, sample_rate)).collect()).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryFiles {
|
||||
writes: Mutex<Vec<(String, Vec<u8>)>>,
|
||||
}
|
||||
impl Services for MemoryFiles {
|
||||
fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {
|
||||
Err(std::io::ErrorKind::NotFound.into())
|
||||
}
|
||||
fn write_file(&self, path: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
self.writes.lock().unwrap().push((path.to_owned(), bytes.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
fn export_in_memory(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn add_source(s: &mut Session, streams: usize) -> (ItemId, Calls) {
|
||||
let source = Arc::new(Tones::new(streams));
|
||||
let calls = source.calls.clone();
|
||||
let mut project = (*s.project).clone();
|
||||
let item = project.add_item(
|
||||
"distinct tones",
|
||||
Label::Iris,
|
||||
ItemKind::Media(MediaClip {
|
||||
media: MediaRef::File { path: format!("tone-source-{streams}.wav") },
|
||||
info: source.info.clone(),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
s.project = Arc::new(project);
|
||||
s.media.insert(item, source);
|
||||
(item, calls)
|
||||
}
|
||||
|
||||
fn fixture() -> (Session, ItemId, ItemId, Calls, Arc<MemoryFiles>) {
|
||||
let files = Arc::new(MemoryFiles::default());
|
||||
let mut session = Session::new(files.clone());
|
||||
let mut project = (*session.project).clone();
|
||||
let sequence =
|
||||
project.new_sequence("streams", SequenceSettings { width: 32, height: 18, frame_rate: FrameRate::FPS_24, ..Default::default() }, 0, 1, None);
|
||||
session.project = Arc::new(project);
|
||||
session.state.active_sequence = Some(sequence);
|
||||
session.state.open_sequences = vec![sequence];
|
||||
let (item, calls) = add_source(&mut session, 3);
|
||||
session.state.project_selection = vec![item];
|
||||
session.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
(session, item, sequence, calls, files)
|
||||
}
|
||||
|
||||
fn place(s: &mut Session, item: ItemId, seconds: f64) -> Vec<ClipId> {
|
||||
let result = s.execute("timeline.place", json!({"item": item.0, "track": "A1", "seconds": seconds})).unwrap();
|
||||
result["clips"].as_array().unwrap().iter().map(|id| ClipId(id.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_essence_distinguishes_streams_and_linked_refusal_precedes_writes() {
|
||||
use crate::aaf_omf::{AudioMode, AudioPlan};
|
||||
use filmcraft_interchange::essence::{EssenceData, EssenceKey, NestNeeds};
|
||||
let (mut s, item, sequence, calls, files) = fixture();
|
||||
place(&mut s, item, 0.0);
|
||||
let plan = AudioPlan {
|
||||
mode: AudioMode::Embedded,
|
||||
aiff: false,
|
||||
mxf: false,
|
||||
sample_rate: 48_000,
|
||||
bits: 16,
|
||||
trim: true,
|
||||
handles: Tick::ZERO,
|
||||
render_effects: false,
|
||||
breakout: false,
|
||||
nests: NestNeeds::Inside,
|
||||
};
|
||||
let (essences, paths) = crate::aaf_omf::prepare_audio(&s, sequence, &plan, "ignored").unwrap();
|
||||
assert!(paths.is_empty());
|
||||
assert_eq!(essences.len(), 3);
|
||||
for (stream, essence) in essences.iter().enumerate() {
|
||||
assert_eq!(essence.key, EssenceKey::media(item, stream));
|
||||
let EssenceData::Embedded(bytes) = &essence.data else { panic!("expected embedded PCM") };
|
||||
for channel in 0..2 {
|
||||
let offset = (25 * 2 + channel) * 2;
|
||||
let actual = i16::from_le_bytes([bytes[offset], bytes[offset + 1]]);
|
||||
let expected = (Tones::sample(stream, channel, 25, 48_000) * 32767.0).round() as i16;
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
let options = filmcraft_interchange::aaf::AafOptions {
|
||||
media: filmcraft_interchange::essence::MediaOptions { essence: essences, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let (aaf, _) = filmcraft_interchange::aaf::export(&s.project, sequence, &options).unwrap();
|
||||
let omf_options = filmcraft_interchange::omf::OmfOptions { media: options.media.clone(), ..Default::default() };
|
||||
let (omf, _) = filmcraft_interchange::omf::export(&s.project, sequence, &omf_options).unwrap();
|
||||
for (kind, document) in [("AAF", aaf), ("OMF", omf)] {
|
||||
let (imported, extracted, _) = if kind == "AAF" {
|
||||
filmcraft_interchange::aaf::import(&document, &Default::default()).unwrap()
|
||||
} else {
|
||||
filmcraft_interchange::omf::import(&document, &Default::default()).unwrap()
|
||||
};
|
||||
assert_eq!(extracted.len(), 3, "{kind}: composition memo must retain distinct selected-stream essence");
|
||||
let tracks = &imported.project.sequence(imported.sequences[0]).unwrap().audio_tracks;
|
||||
assert_eq!(tracks.len(), 3, "{kind}");
|
||||
let mut source_ids: Vec<_> = tracks.iter().map(|track| track.items[0].item).collect();
|
||||
source_ids.sort();
|
||||
source_ids.dedup();
|
||||
assert_eq!(source_ids.len(), 3, "{kind}: each stream remains a distinct imported source");
|
||||
let mut waveforms: Vec<_> = extracted.iter().map(|media| media.wav.clone()).collect();
|
||||
waveforms.sort();
|
||||
waveforms.dedup();
|
||||
assert_eq!(waveforms.len(), 3, "{kind}");
|
||||
}
|
||||
assert!(calls.lock().unwrap().iter().any(|call| call.0 == 2));
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
let error =
|
||||
s.execute("file.exportAaf", json!({"path": "must-not-be-written.aaf", "sequence": sequence.0, "audio": "linked", "trimAudio": false})).unwrap_err();
|
||||
assert!(error.to_string().contains("embedded") || error.to_string().contains("separate"));
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,50 @@ fn placing_media_with_two_streams_expands_tracks_and_keeps_stream_indices() {
|
|||
assert_eq!(a.start, b.start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_peaks_measure_the_stream_the_clip_plays() {
|
||||
let mut s = Session::default();
|
||||
let mut p = (*s.project).clone();
|
||||
let rate = FrameRate::FPS_24;
|
||||
let seq = p.new_sequence("Two streams", Default::default(), 1, 1, None);
|
||||
let source = filmcraft_media::generators::GeneratorSource::new(filmcraft_media::Generator::BarsAndTone, 32, 32, rate, rate.tick_of(24));
|
||||
let mut info = source.info().clone();
|
||||
info.audio_streams.push(info.audio_streams[0].clone());
|
||||
let item = p.add_item(
|
||||
"two streams",
|
||||
filmcraft_project::Label::Iris,
|
||||
filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip {
|
||||
media: filmcraft_project::MediaRef::Generator(filmcraft_media::Generator::BarsAndTone),
|
||||
info,
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let (vdest, adest) = {
|
||||
let q = p.sequence(seq).unwrap();
|
||||
(q.video_tracks[0].id, q.audio_tracks[0].id)
|
||||
};
|
||||
s.project = std::sync::Arc::new(p);
|
||||
s.state.active_sequence = Some(seq);
|
||||
let ids =
|
||||
crate::commands::place_item(&mut s, item, TimeRange::new(Tick::ZERO, rate.tick_of(24)), Tick::ZERO, Some(vdest), Some(adest), false, "test", None)
|
||||
.unwrap();
|
||||
let q = s.project.sequence(seq).unwrap();
|
||||
let (a, b) = (q.audio_tracks[0].items[0].id, q.audio_tracks[1].items[0].id);
|
||||
assert!(ids.contains(&a) && ids.contains(&b));
|
||||
let peaks = crate::mixer::clip_peaks(&s, &[a, b]);
|
||||
let db = |c: filmcraft_project::ClipId| peaks.iter().find(|(id, _)| *id == c).map(|(_, d)| *d).unwrap();
|
||||
// The generator only has stream 0, so the clip playing stream 1 is silent, not a copy of stream 0.
|
||||
assert!(db(a) > -30.0, "{peaks:?}");
|
||||
assert!(db(b) < -100.0, "{peaks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_audio_tracks_are_cloned_from_the_destination() {
|
||||
let s = demo();
|
||||
|
|
|
|||
|
|
@ -513,7 +513,11 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
enabled: always,
|
||||
run: |s, p| {
|
||||
let Some(seq) = s.active_sequence() else { return Ok(json!({"tracks": []})) };
|
||||
let only = p.get("track").and_then(|_| track_param(s, p));
|
||||
// a `track` that names no caption track is an error, never every track
|
||||
let only = match p.get("track") {
|
||||
Some(_) => Some(track_param(s, p).ok_or_else(|| bad("captions.list", "no such caption track"))?),
|
||||
None => None,
|
||||
};
|
||||
let tracks: Vec<Value> =
|
||||
seq.caption_tracks.iter().enumerate().filter(|(_, t)| only.is_none_or(|o| o == t.id)).map(|(i, t)| track_json(s, t, i)).collect();
|
||||
Ok(json!({"tracks": tracks, "selection": s.state.caption_selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
|
|
|
|||
|
|
@ -137,3 +137,24 @@ fn caption_commands_need_tracks_and_navigate() {
|
|||
s.execute("captions.deleteTrack", json!({"track": "C1"})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().caption_tracks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_list_track_filter_never_falls_back_to_every_track() {
|
||||
let mut s = demo();
|
||||
s.execute("captions.newTrack", json!({})).unwrap();
|
||||
s.execute("captions.newTrack", json!({})).unwrap();
|
||||
let all = s.execute("captions.list", json!({})).unwrap();
|
||||
let ids: Vec<u64> = all["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(ids.len(), 2, "{all}");
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
for sel in [json!(id), json!(format!("C{}", i + 1))] {
|
||||
let r = s.execute("captions.list", json!({"track": sel})).unwrap();
|
||||
let got: Vec<u64> = r["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(got, vec![*id], "{sel}");
|
||||
}
|
||||
}
|
||||
let missing = ids.iter().max().unwrap() + 999;
|
||||
for sel in [json!(missing), json!("C3"), json!("C0"), json!("nope")] {
|
||||
assert!(s.execute("captions.list", json!({"track": sel})).is_err(), "{sel} names no caption track");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -901,3 +901,25 @@ fn add_edit_cuts_only_the_selected_clips() {
|
|||
let r = s.execute("sequence.addEdit", json!({"time": t.0})).unwrap();
|
||||
assert!(r["cuts"].as_u64().unwrap() > 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newProject", json!({"name": "Slip"})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 1, "width": 16, "height": 16})).unwrap();
|
||||
let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap();
|
||||
let item = r["item"].as_u64().unwrap();
|
||||
let fr = s.sequence_rate().tick_of(1);
|
||||
let r =
|
||||
s.execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})).unwrap();
|
||||
let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap());
|
||||
// lengthen only the picture so the pair has unequal tail handles
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": v, "edge": "out", "deltaFrames": 20})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": 30})).unwrap();
|
||||
assert_eq!(clip(&s, v).source_in, Tick(fr.0 * 20));
|
||||
assert_eq!(clip(&s, a).source_in, Tick(fr.0 * 20), "sound must not slip alone");
|
||||
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": -10})).unwrap();
|
||||
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1325,13 +1325,17 @@ fn build() -> Vec<CommandSpec> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
),
|
||||
// Each clip flips on its own, as in Premiere (#484): with one enabled and one disabled clip
|
||||
// selected, Enable swaps them rather than first making both the same.
|
||||
cmd!("clip.enable", "Enable", ["Clip"], Some("Shift+E"), r#"{"clips":[id]?}"#, has_selection, |s, p| {
|
||||
let sel = with_links(s, &clips_p(s, p));
|
||||
let mut sel = with_links(s, &clips_p(s, p));
|
||||
// a clip named twice must still flip once
|
||||
sel.sort();
|
||||
sel.dedup();
|
||||
s.edit_sequence("Enable", |q, _, _| {
|
||||
let target = !sel.iter().all(|c| q.find_item(*c).is_some_and(|(_, i)| i.enabled));
|
||||
for c in &sel {
|
||||
if let Some((_, i)) = q.find_item_mut(*c) {
|
||||
i.enabled = target;
|
||||
i.enabled = !i.enabled;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -1837,6 +1841,15 @@ fn build() -> Vec<CommandSpec> {
|
|||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!(
|
||||
"markers.exportCsv",
|
||||
"Export Markers as CSV…",
|
||||
["Markers"],
|
||||
None,
|
||||
r#"{"path":str} (all markers in the active sequence)"#,
|
||||
has_seq,
|
||||
crate::marker_export::export
|
||||
),
|
||||
cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.clearAll", p)? {
|
||||
return Ok(v);
|
||||
|
|
@ -1889,8 +1902,11 @@ fn build() -> Vec<CommandSpec> {
|
|||
cmd!("playhead.step", "Step Frames", [], None, r#"{"frames":i64}"#, has_seq, |s, p| {
|
||||
let n = p.get("frames").and_then(Value::as_i64).unwrap_or(1);
|
||||
let r = s.sequence_rate();
|
||||
let f = r.frame_at(s.playhead()) + n;
|
||||
s.set_playhead(r.tick_of(f.max(0)));
|
||||
let f = r.frame_at(s.playhead()).checked_add(n).ok_or_else(|| bad("playhead.step", "frame offset is too large"))?.max(0);
|
||||
if f > r.frame_at(Tick::MAX) {
|
||||
return Err(bad("playhead.step", "target frame exceeds the supported time range"));
|
||||
}
|
||||
s.set_playhead(r.tick_of(f));
|
||||
Ok(json!({"frame": r.frame_at(s.playhead())}))
|
||||
}),
|
||||
cmd!("playhead.stepForward", "Step Forward One Frame", [], Some("Right"), "{}", has_seq, |s, _| s.execute("playhead.step", json!({"frames": 1}))),
|
||||
|
|
@ -2203,8 +2219,16 @@ fn build() -> Vec<CommandSpec> {
|
|||
.unwrap_or_default();
|
||||
let clips = with_links(s, &[c]);
|
||||
s.edit_sequence("Slip", |q, ctx, _| {
|
||||
// clamp across all linked partners, then slip them by the common delta
|
||||
let mut dd = d;
|
||||
for c in &clips {
|
||||
edit::slip(q, *c, d, ctx)?;
|
||||
let x = edit::slip(&mut q.clone(), *c, dd, ctx)?;
|
||||
if x.abs() < dd.abs() {
|
||||
dd = x;
|
||||
}
|
||||
}
|
||||
for c in &clips {
|
||||
edit::slip(q, *c, dd, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
@ -2497,7 +2521,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Move Keyframe",
|
||||
[],
|
||||
None,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks}"#,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks,"value":any?,"merge":bool?,"begin":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| keyframe_op(s, p, "move")
|
||||
),
|
||||
|
|
@ -2515,7 +2539,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Edit Keyframe",
|
||||
[],
|
||||
None,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?}"#,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?,"merge":bool?,"begin":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| keyframe_op(s, p, "set")
|
||||
),
|
||||
|
|
@ -2704,7 +2728,12 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
|||
"set" => "Edit Keyframe",
|
||||
_ => "Keyframe Interpolation",
|
||||
};
|
||||
s.edit_sequence(label, |q, _, _| {
|
||||
// a keyframe dragged on the timeline is one undo step, like a dragged value
|
||||
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("keyframe:{op}:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null)));
|
||||
if bool_p(p, "begin").unwrap_or(false) {
|
||||
s.history.merge_key = None;
|
||||
}
|
||||
s.edit_sequence_as(label, merge.as_deref(), |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let e = match &eff {
|
||||
|
|
@ -2730,6 +2759,9 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
|||
if let Some(i) = prm.keyframes.iter().position(|k| k.time == from) {
|
||||
let mut k = prm.keyframes.remove(i);
|
||||
k.time = to;
|
||||
if let Some(v) = p.get("value") {
|
||||
k.value = json_to_param(&k.value, v).ok_or_else(|| bad("keyframe", "value has the wrong type"))?;
|
||||
}
|
||||
prm.keyframes.retain(|x| x.time != to);
|
||||
let at = prm.keyframes.partition_point(|x| x.time < to);
|
||||
prm.keyframes.insert(at, k);
|
||||
|
|
@ -3183,6 +3215,16 @@ fn set_transition(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value> {
|
||||
let (track, tr) = preview_transition(s, p, kind)?;
|
||||
let label = format!("Apply {}", tr.effect.def().map_or("Transition", |d| d.name));
|
||||
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
|
||||
Ok(json!({"transition": id.0}))
|
||||
}
|
||||
|
||||
/// Plan an applied transition without editing the project or allocating an id.
|
||||
/// Uses the same `effect`, `clip`, `edge`, `frames` and settings as transition application,
|
||||
/// so a drag preview shows the exact range that release will commit.
|
||||
pub fn preview_transition(s: &Session, p: &Value, kind: TrackKind) -> Result<(TrackId, Transition)> {
|
||||
let eff_id = str_p(p, "effect")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| if kind == TrackKind::Video { s.state.default_video_transition.clone() } else { s.state.default_audio_transition.clone() });
|
||||
|
|
@ -3233,6 +3275,9 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value
|
|||
}
|
||||
}
|
||||
let (track, from, to, cut) = found.ok_or_else(|| EngineError::Other("no edit point at the playhead on targeted tracks".into()))?;
|
||||
if q.track(track).is_some_and(|tr| tr.locked) {
|
||||
return Err(edit::EditError::Locked.into());
|
||||
}
|
||||
let start = if from.is_some() && to.is_some() {
|
||||
cut - dur.mul_ratio(1, 2)
|
||||
} else if to.is_some() {
|
||||
|
|
@ -3241,9 +3286,7 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value
|
|||
cut - dur
|
||||
};
|
||||
let tr = Transition { id: TransitionId(0), effect: instance, start: rate.snap(start), duration: dur, from, to, align: Default::default(), reverse };
|
||||
let label = format!("Apply {}", def.name);
|
||||
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
|
||||
Ok(json!({"transition": id.0}))
|
||||
Ok((track, tr))
|
||||
}
|
||||
|
||||
/// JSON description of the project (bins, items) for agents.
|
||||
|
|
|
|||
|
|
@ -64,6 +64,19 @@ fn export_integer_parameters_cannot_wrap_or_overflow() {
|
|||
assert!(s.execute("export.resolve", json!({"bitrateKbps":8000.0, "keyframeDistance":48.0})).is_ok(), "integer-valued floats are integers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_camel_case_settings_are_honoured() {
|
||||
let mut s = demo();
|
||||
let camel = json!({"audio":{"sampleRate":96000}, "effects":{"loudness":{"enabled":true, "targetLufs":-16}}});
|
||||
let snake = json!({"audio":{"sample_rate":96000}, "effects":{"loudness":{"enabled":true, "target_lufs":-16}}});
|
||||
let camel = s.execute("export.resolve", json!({"format":"wav", "settings":camel})).unwrap();
|
||||
let snake = s.execute("export.resolve", json!({"format":"wav", "settings":snake})).unwrap();
|
||||
assert_eq!(camel["output"]["sampleRate"], 96000);
|
||||
assert_eq!(camel["settings"]["audio"]["sample_rate"], 96000);
|
||||
assert_eq!(camel["settings"]["effects"]["loudness"]["target_lufs"], -16.0);
|
||||
assert_eq!(camel["settings"], snake["settings"]);
|
||||
}
|
||||
|
||||
fn probe(path: &str) -> Option<Value> {
|
||||
let ffprobe = filmcraft_testkit::ffprobe_or_skip("export presets")?;
|
||||
let out = std::process::Command::new(ffprobe).args(["-v", "error", "-of", "json", "-show_format", "-show_streams", path]).output().unwrap();
|
||||
|
|
@ -359,6 +372,22 @@ fn queue_orders_cancels_and_retries() {
|
|||
assert!(queue(&mut s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_move_by_extreme_offsets_clamps_instead_of_overflowing() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = (0..3)
|
||||
.map(|_| {
|
||||
let r = s.execute("export.queue.add", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": "queued-output/"})).unwrap();
|
||||
r["added"][0].as_u64().unwrap()
|
||||
})
|
||||
.collect();
|
||||
let order = |s: &mut Session| -> Vec<u64> { queue(s).iter().map(|i| i["id"].as_u64().unwrap()).collect() };
|
||||
s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MAX})).unwrap();
|
||||
assert_eq!(order(&mut s), [ids[0], ids[2], ids[1]]);
|
||||
s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MIN})).unwrap();
|
||||
assert_eq!(order(&mut s), [ids[1], ids[0], ids[2]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_cancels_a_running_export_and_retries_a_failed_one() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -129,12 +129,30 @@ fn parse_file(bytes: &[u8]) -> std::result::Result<PresetFile, String> {
|
|||
// settings
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `sampleRate` → `sample_rate`.
|
||||
fn camel_to_snake(k: &str) -> String {
|
||||
let mut out = String::with_capacity(k.len() + 2);
|
||||
for c in k.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('_');
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Deep-merge `patch` into `base` (objects merge, everything else replaces).
|
||||
fn merge(base: &mut Value, patch: &Value) {
|
||||
match (base, patch) {
|
||||
(Value::Object(b), Value::Object(p)) => {
|
||||
for (k, v) in p {
|
||||
merge(b.entry(k.clone()).or_insert(Value::Null), v);
|
||||
// Some nested settings structs serialize snake_case (`audio.sample_rate`); accept the documented camelCase
|
||||
// spelling for them too instead of adding a second, ignored key next to the default.
|
||||
let snake = camel_to_snake(k);
|
||||
let key = if !b.contains_key(k) && b.contains_key(&snake) { snake } else { k.clone() };
|
||||
merge(b.entry(key).or_insert(Value::Null), v);
|
||||
}
|
||||
}
|
||||
(b, p) => *b = p.clone(),
|
||||
|
|
@ -769,7 +787,7 @@ fn queue_move(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let n = s.export_queue.items.len() as i64;
|
||||
let to = match (p.get("to").and_then(Value::as_i64), p.get("by").and_then(Value::as_i64)) {
|
||||
(Some(t), _) => t,
|
||||
(None, Some(b)) => i as i64 + b,
|
||||
(None, Some(b)) => (i as i64).saturating_add(b),
|
||||
_ => return Err(bad(cmd, "need `to` (index) or `by` (±n)")),
|
||||
}
|
||||
.clamp(0, n - 1) as usize;
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ fn apply_template(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (layers, meta) = t.instantiate((w, h));
|
||||
let before = s.history.undo.len();
|
||||
let dur = t.duration;
|
||||
let clip = place_video_clip(s, &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| {
|
||||
let clip = place_video_clip(s, "graphics.template.apply", &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| {
|
||||
let src = crate::graphics::graphic_source(pr, w, h, rate);
|
||||
(src, rate.snap_nearest(dur))
|
||||
})?;
|
||||
|
|
@ -911,7 +911,7 @@ fn upgrade_caption(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = text.lines().next().unwrap_or("Caption").chars().take(40).collect::<String>();
|
||||
let dur = c.duration;
|
||||
let q = json!({"time": c.start.0});
|
||||
let clip = place_video_clip(s, &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
let clip = place_video_clip(s, "graphics.upgradeCaption", &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
(crate::graphics::graphic_source(pr, w, h, rate), dur)
|
||||
})?;
|
||||
clips.push(clip.0);
|
||||
|
|
|
|||
|
|
@ -96,23 +96,25 @@ pub(crate) fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32,
|
|||
|
||||
/// Place a new graphic clip holding `layer` at the playhead: on the first video track above the
|
||||
/// topmost clip at the playhead that is free for the duration (a track is added if needed).
|
||||
fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
|
||||
fn new_graphic_clip(s: &mut Session, cmd: &str, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
|
||||
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
|
||||
// Here `seconds` is duration, not the generic time parser's placement alias.
|
||||
let mut placement = p.clone();
|
||||
if let Some(params) = placement.as_object_mut() {
|
||||
params.remove("seconds");
|
||||
}
|
||||
place_video_clip(s, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
place_video_clip(s, cmd, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
let src = graphic_source(pr, w, h, rate);
|
||||
(src, rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Place a video clip of the item `source` returns (with its duration) at the playhead (or
|
||||
/// `time`), above the clips there, as one undo step; selects it.
|
||||
/// `time`), above the clips there, as one undo step; selects it. `cmd` is the command that asked,
|
||||
/// for its errors.
|
||||
pub(crate) fn place_video_clip(
|
||||
s: &mut Session,
|
||||
cmd: &str,
|
||||
name: &str,
|
||||
p: &Value,
|
||||
label: &str,
|
||||
|
|
@ -133,7 +135,7 @@ pub(crate) fn place_video_clip(
|
|||
let src_size = pr.source_size(src).unwrap_or((w, h));
|
||||
let t = rate.snap(t);
|
||||
let dur = dur.max(rate.frame_duration());
|
||||
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?;
|
||||
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?;
|
||||
ti.name = name;
|
||||
ti.effects.extend(extra);
|
||||
for e in &mut ti.effects {
|
||||
|
|
@ -146,7 +148,7 @@ pub(crate) fn place_video_clip(
|
|||
let free = |tr: &filmcraft_project::Track| !tr.items.iter().any(|i| i.range().overlaps(&range));
|
||||
let idx = match want_track {
|
||||
Some(i) if i < q.video_tracks.len() && free(&q.video_tracks[i]) => i,
|
||||
Some(i) if i < q.video_tracks.len() => return Err(bad("graphics.newText", format!("V{} is not free here", i + 1))),
|
||||
Some(i) if i < q.video_tracks.len() => return Err(bad(cmd, format!("V{} is not free here", i + 1))),
|
||||
_ => {
|
||||
let top = q.video_tracks.iter().rposition(|tr| tr.item_at(t).is_some()).map_or(0, |k| k + 1);
|
||||
match (top..q.video_tracks.len()).find(|&i| free(&q.video_tracks[i]) && !q.video_tracks[i].locked) {
|
||||
|
|
@ -190,6 +192,10 @@ fn param_id(k: &str) -> &str {
|
|||
"fontSize" => "size",
|
||||
"fontStyle" | "style" => "font_style",
|
||||
"fillColor" | "color" => "fill_color",
|
||||
"fillKind" => "fill_kind",
|
||||
"gradientStart" => "gradient_start",
|
||||
"gradientEnd" => "gradient_end",
|
||||
"gradientAngle" => "gradient_angle",
|
||||
"strokeColor" => "stroke_color",
|
||||
"strokeWidth" => "stroke_width",
|
||||
"backgroundColor" => "background_color",
|
||||
|
|
@ -212,6 +218,7 @@ pub(crate) fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option<Par
|
|||
"align" => graphic::ALIGN_OPTS,
|
||||
"caps" => graphic::CAPS_OPTS,
|
||||
"stroke_type" | "stroke2_type" => graphic::STROKE_OPTS,
|
||||
"fill_kind" => graphic::FILL_KIND_OPTS,
|
||||
"shape" => SHAPE_OPTS,
|
||||
_ => &[],
|
||||
};
|
||||
|
|
@ -703,7 +710,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
"Rectangle",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
Some("Cmd+Alt+R"),
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| new_shape_cmd(s, p, "rectangle"),
|
||||
),
|
||||
|
|
@ -712,7 +719,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
"Ellipse",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
Some("Cmd+Alt+E"),
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| new_shape_cmd(s, p, "ellipse"),
|
||||
),
|
||||
|
|
@ -721,7 +728,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
"Polygon",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
None,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?}"#,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
|
|
@ -766,7 +773,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
return Err(bad("graphics.newFromFile", "the file has no picture"));
|
||||
}
|
||||
let (name, dur) = (pi.name.clone(), pi.duration());
|
||||
let clip = place_video_clip(s, &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
|
||||
let clip = place_video_clip(s, "graphics.newFromFile", &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
|
||||
s.state.graphic_layers.clear();
|
||||
Ok(json!({"clip": clip.0, "item": item.0}))
|
||||
},
|
||||
|
|
@ -902,7 +909,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
Some(c) => (c, add_layer(s, c, layer)?),
|
||||
None => {
|
||||
let name = text.lines().next().filter(|l| !l.trim().is_empty()).unwrap_or("Graphic").to_string();
|
||||
(new_graphic_clip(s, layer, &name, p)?, 0)
|
||||
(new_graphic_clip(s, "graphics.newText", layer, &name, p)?, 0)
|
||||
}
|
||||
};
|
||||
Ok(json!({"clip": clip.0, "layer": layer_i}))
|
||||
|
|
@ -913,7 +920,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
"Shape",
|
||||
&[],
|
||||
None,
|
||||
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]?,"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id?,"seconds":f64=5}"#,
|
||||
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]? (the shape's centre),"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id? (add the shape to this graphic),"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let shape = str_p(p, "shape").unwrap_or("rectangle").to_ascii_lowercase();
|
||||
|
|
@ -936,7 +943,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
let into = u64_p(p, "clip").map(ClipId).filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c)));
|
||||
let (clip, layer_i) = match into {
|
||||
Some(c) => (c, add_layer(s, c, layer)?),
|
||||
None => (new_graphic_clip(s, layer, "Shape", p)?, 0),
|
||||
None => (new_graphic_clip(s, "graphics.newShape", layer, "Shape", p)?, 0),
|
||||
};
|
||||
Ok(json!({"clip": clip.0, "layer": layer_i}))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -65,6 +65,36 @@ fn graphic_duration_keeps_explicit_time_frame_and_timecode_placement() {
|
|||
}
|
||||
}
|
||||
|
||||
/// A new graphic goes on the track and at the time it is given, for a shape as for text; a track
|
||||
/// that is not free there is an error named after the command that asked.
|
||||
#[test]
|
||||
fn graphic_placement_takes_a_track_and_a_time() {
|
||||
let commands =
|
||||
[("graphics.newText", json!({})), ("graphics.newShape", json!({})), ("graphics.newRectangle", json!({})), ("graphics.newPolygon", json!({"sides": 5}))];
|
||||
for (command, mut params) in commands {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 3, "audio": 1})).unwrap();
|
||||
s.execute("playhead.set", json!({"frame": 72})).unwrap();
|
||||
params["track"] = json!(2);
|
||||
params["frame"] = json!(24);
|
||||
params["seconds"] = json!(1);
|
||||
let r = s.execute(command, params.clone()).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (track, it) = q.find_item(clip).unwrap();
|
||||
assert_eq!(track, q.video_tracks[2].id, "{command}");
|
||||
assert_eq!((it.start, it.duration), (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(1.0)), "{command}");
|
||||
// the same place again is taken
|
||||
let e = s.execute(command, params).unwrap_err().to_string();
|
||||
assert!(e.contains("V3 is not free here"), "{command}: {e}");
|
||||
let named = if command == "graphics.newText" { "graphics.newText" } else { "graphics.newShape" };
|
||||
assert!(e.contains(named), "{command}: {e}");
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks[2].items.len(), 1, "{command}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(clip).is_none(), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_makes_a_graphic_clip_above_the_footage() {
|
||||
let mut s = demo();
|
||||
|
|
@ -149,6 +179,24 @@ fn align_and_distribute_layers() {
|
|||
let _ = w;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_gradient_fill_sets_and_evaluates() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newShape", json!({"shape": "rectangle", "position": [200, 200], "size": [80, 40]})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
s.execute(
|
||||
"graphics.set",
|
||||
json!({"clip": clip.0, "props": {"fill_kind": "linear gradient", "gradient_start": "#ff0000", "gradient_end": "#0000ff", "gradient_angle": 0}}),
|
||||
)
|
||||
.unwrap();
|
||||
let g = layers(&s, clip)[0].appearance.gradient.clone().expect("linear fill");
|
||||
assert_eq!(g.start[0], 1.0);
|
||||
assert!(g.start[1] < 0.01 && g.end[2] > 0.9 && g.end[0] < 0.01, "{g:?}");
|
||||
assert_eq!(g.angle, 0.0);
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "props": {"fill_kind": "solid"}})).unwrap();
|
||||
assert!(layers(&s, clip)[0].appearance.gradient.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_renders_in_the_program_and_survives_save() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub mod graphic_templates;
|
|||
pub mod graphics;
|
||||
pub mod interchange;
|
||||
pub mod keyboard;
|
||||
mod marker_export;
|
||||
pub mod masks;
|
||||
pub mod media_browser;
|
||||
pub mod media_pool;
|
||||
|
|
@ -707,14 +708,15 @@ impl Session {
|
|||
open_sequences: self.state.open_sequences.iter().copied().filter(is_seq).collect(),
|
||||
active_sequence: self.state.active_sequence.filter(is_seq),
|
||||
sequences: self.state.timeline_views.iter().filter(|(id, _)| is_seq(id)).map(|(id, v)| (*id, *v)).collect(),
|
||||
playheads: self.state.playheads.iter().filter(|(id, _)| is_seq(id)).map(|(id, t)| (*id, *t)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open what was open when the project was saved. Nothing in `view` is trusted: ids that are
|
||||
/// not sequences of this project are dropped (also a second mention of the same sequence),
|
||||
/// and numbers are brought into range. A view without any open sequence leaves the project
|
||||
/// on its first sequence: a project saved by a session that never showed one (a script, the
|
||||
/// CLI) should not open on an empty Timeline.
|
||||
/// and numbers are brought into range (a playhead also lands on a frame of its sequence). A
|
||||
/// view without any open sequence leaves the project on its first sequence: a project saved
|
||||
/// by a session that never showed one (a script, the CLI) should not open on an empty Timeline.
|
||||
pub fn restore_project_view(&mut self, view: filmcraft_project::ProjectView) {
|
||||
let mut open: Vec<ItemId> = Vec::new();
|
||||
for id in view.open_sequences {
|
||||
|
|
@ -728,6 +730,12 @@ impl Session {
|
|||
}
|
||||
self.state.timeline_views =
|
||||
view.sequences.into_iter().filter(|(id, _)| self.project.sequence(*id).is_some()).filter_map(|(id, v)| Some((id, v.checked()?))).collect();
|
||||
for (id, t) in view.playheads {
|
||||
if let Some(seq) = self.project.sequence(id) {
|
||||
let t = t.clamp(Tick::ZERO, filmcraft_project::ProjectView::MAX_PLAYHEAD);
|
||||
self.state.playheads.insert(id, seq.settings.frame_rate.snap(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every edit passes through here: one that would put a sequence inside itself (directly or
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
//! Sequence review notes as a spreadsheet-safe CSV, using the host's atomic file service.
|
||||
|
||||
use filmcraft_project::Label;
|
||||
use filmcraft_time::{Tick, format_timecode_frames};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::bad;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "markers.exportCsv";
|
||||
const MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_MARKERS: usize = 100_000;
|
||||
|
||||
fn label_name(s: &Session, color: Label) -> &str {
|
||||
s.prefs.labels.colors.get(&crate::settings::label_id(color)).map(|c| c.name.trim()).filter(|n| !n.is_empty()).unwrap_or(color.name())
|
||||
}
|
||||
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|v| !v.trim().is_empty() && !v.contains('\0'))
|
||||
.ok_or_else(|| bad(CMD, "need a non-empty `path` without NUL characters"))?;
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
seq.settings.validate().map_err(|e| bad(CMD, e))?;
|
||||
let name = s.state.active_sequence.and_then(|id| s.project.item(id)).map(|i| i.name.as_str()).unwrap_or("Sequence");
|
||||
if seq.markers.len() > MAX_MARKERS {
|
||||
return Err(bad(CMD, "too many markers (maximum 100000)"));
|
||||
}
|
||||
// Bound the worst-case quoted output before sorting or allocating it. 1024 covers numeric,
|
||||
// timecode and enum fields; arbitrary text may double in size when every character is a quote.
|
||||
let mut bound = 512usize;
|
||||
for m in &seq.markers {
|
||||
let row = name
|
||||
.len()
|
||||
.checked_add(m.name.len())
|
||||
.and_then(|n| n.checked_add(m.comment.len()))
|
||||
.and_then(|n| n.checked_add(label_name(s, m.color).len()))
|
||||
.and_then(|n| n.checked_mul(2))
|
||||
.and_then(|n| n.checked_add(1024));
|
||||
bound = row.and_then(|n| bound.checked_add(n)).filter(|n| *n <= MAX_BYTES).ok_or_else(|| bad(CMD, "marker report exceeds the 16 MiB limit"))?;
|
||||
}
|
||||
let rate = seq.settings.frame_rate;
|
||||
let mut markers: Vec<_> = seq.markers.iter().collect();
|
||||
markers.sort_by_key(|m| (m.start, m.id));
|
||||
let mut csv =
|
||||
String::from("Sequence,Marker ID,Name,Comment,Kind,Color,Start Timecode,End Timecode,Duration Frames,Start Ticks,Duration Ticks,Frame Rate\r\n");
|
||||
for m in markers {
|
||||
if m.start.0 < 0 || m.duration.0 < 0 {
|
||||
return Err(bad(CMD, format!("marker {} has a negative start or duration", m.id.0)));
|
||||
}
|
||||
let end = m.start.0.checked_add(m.duration.0).ok_or_else(|| bad(CMD, format!("marker {} end overflows", m.id.0)))?;
|
||||
let tc = |t: Tick| -> Result<String> {
|
||||
let frame = rate.frame_at(t).checked_add(seq.start_timecode).ok_or_else(|| bad(CMD, "sequence timecode overflows"))?;
|
||||
Ok(format_timecode_frames(frame, rate, seq.settings.drop_frame))
|
||||
};
|
||||
let fields = [
|
||||
name.to_string(),
|
||||
m.id.0.to_string(),
|
||||
m.name.clone(),
|
||||
m.comment.clone(),
|
||||
format!("{:?}", m.kind),
|
||||
label_name(s, m.color).to_string(),
|
||||
tc(m.start)?,
|
||||
tc(Tick(end))?,
|
||||
rate.frame_at(m.duration).to_string(),
|
||||
m.start.0.to_string(),
|
||||
m.duration.0.to_string(),
|
||||
format!("{}/{}", rate.num, rate.den),
|
||||
];
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
csv.push(',');
|
||||
}
|
||||
csv.push('"');
|
||||
// Quoting alone does not stop spreadsheet formulas. Escape only arbitrary text,
|
||||
// including formulas preceded by whitespace; numeric/timecode fields stay numeric.
|
||||
if matches!(i, 0 | 2 | 3 | 5) && field.trim_start().starts_with(['=', '+', '-', '@']) {
|
||||
csv.push('\'');
|
||||
}
|
||||
for ch in field.chars() {
|
||||
if ch == '"' {
|
||||
csv.push('"');
|
||||
}
|
||||
csv.push(ch);
|
||||
}
|
||||
csv.push('"');
|
||||
}
|
||||
csv.push_str("\r\n");
|
||||
}
|
||||
s.services.write_file(path, csv.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "markers": seq.markers.len(), "bytes": csv.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use filmcraft_project::{Label, Marker, MarkerId, MarkerKind};
|
||||
use filmcraft_time::FrameRate;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Files {
|
||||
writes: Mutex<Vec<(String, Vec<u8>)>>,
|
||||
fail: bool,
|
||||
}
|
||||
impl Services for Files {
|
||||
fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {
|
||||
Err(std::io::ErrorKind::NotFound.into())
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if self.fail {
|
||||
return Err(std::io::ErrorKind::PermissionDenied.into());
|
||||
}
|
||||
self.writes.lock().unwrap().push((path.into(), data.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn session(files: Arc<Files>) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.services = files;
|
||||
s.execute("file.newSequence", json!({"name":"Review, 日本語", "fps":24})).unwrap();
|
||||
s
|
||||
}
|
||||
fn marker(id: u64, start: Tick, duration: Tick, name: &str) -> Marker {
|
||||
Marker {
|
||||
id: MarkerId(id),
|
||||
start,
|
||||
duration,
|
||||
name: name.into(),
|
||||
comment: "First line\r\nSecond, \"quoted\"".into(),
|
||||
kind: MarkerKind::Chapter,
|
||||
color: Label::Iris,
|
||||
}
|
||||
}
|
||||
fn set_markers(s: &mut Session, markers: Vec<Marker>) {
|
||||
s.edit_sequence("Review markers", |q, _, _| {
|
||||
q.markers = markers;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
fn written(files: &Files) -> String {
|
||||
String::from_utf8(files.writes.lock().unwrap().last().unwrap().1.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exports_sorted_unicode_review_notes_without_changing_the_edit() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let rate = FrameRate::FPS_24;
|
||||
set_markers(&mut s, vec![marker(100, rate.tick_of(48), rate.tick_of(24), "Later"), marker(101, rate.tick_of(24), Tick::ZERO, "早い")]);
|
||||
s.execute("markers.markIn", json!({"frame":12})).unwrap();
|
||||
s.state.hidden_marker_colors.push(Label::Iris);
|
||||
let before = s.project.clone();
|
||||
let state = serde_json::to_value(&s.state).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
let result = s.execute(CMD, json!({"path":"review.csv"})).unwrap();
|
||||
let csv = written(&files);
|
||||
assert_eq!(result["markers"], 2);
|
||||
assert_eq!(result["bytes"], csv.len());
|
||||
assert!(csv.contains("\"Review, 日本語\",\"101\",\"早い\",\"First line\r\nSecond, \"\"quoted\"\"\""));
|
||||
assert!(csv.find("\"101\"").unwrap() < csv.find("\"100\"").unwrap());
|
||||
assert!(csv.contains("\"00:00:02:00\",\"00:00:03:00\",\"24\",\"508032000000\",\"254016000000\",\"24/1\"\r\n"));
|
||||
assert_eq!(*s.project, *before);
|
||||
assert_eq!(serde_json::to_value(&s.state).unwrap(), state);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
s.undo();
|
||||
s.redo();
|
||||
assert_eq!(*s.project, *before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_and_sequence_start_timecode_are_used_for_both_bounds() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let rate = FrameRate::FPS_29_97;
|
||||
s.edit_sequence("Timing", |q, _, _| {
|
||||
q.settings.frame_rate = rate;
|
||||
q.settings.drop_frame = true;
|
||||
q.start_timecode = 107892;
|
||||
q.markers = vec![marker(100, rate.tick_of(1800), rate.tick_of(30), "DF")];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute(CMD, json!({"path":"df.csv"})).unwrap();
|
||||
assert!(written(&files).contains("\"01;01;00;02\",\"01;01;01;02\",\"30\""));
|
||||
assert!(written(&files).contains("\"30000/1001\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_formulas_are_escaped_in_all_user_text_columns() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
for text in ["=1+1", "+1+1", "-1+1", "@SUM(1)", "\t =1+1"] {
|
||||
let mut m = marker(100, Tick::ZERO, Tick::ZERO, text);
|
||||
m.comment = text.into();
|
||||
set_markers(&mut s, vec![m]);
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
s.edit("Name", |p, _| {
|
||||
p.items.get_mut(&id).unwrap().name = text.into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute(CMD, json!({"path":"safe.csv"})).unwrap();
|
||||
assert_eq!(written(&files).matches(&format!("\"'{text}\"")).count(), 3);
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = text.into();
|
||||
s.execute(CMD, json!({"path":"safe-label.csv"})).unwrap();
|
||||
assert!(written(&files).contains(&format!("\"Chapter\",\"'{}\"", text.trim())));
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = "Iris".into();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sequence_writes_a_header_and_no_sequence_is_disabled() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let spec = crate::find_command(CMD).unwrap();
|
||||
assert_eq!(spec.menu, ["Markers"]);
|
||||
assert!(spec.params.contains("path"));
|
||||
assert!(spec.shortcut.is_none());
|
||||
assert_eq!(s.execute(CMD, json!({"path":"empty.csv"})).unwrap()["markers"], 0);
|
||||
assert_eq!(written(&files).lines().count(), 1);
|
||||
assert!((spec.enabled)(&Session::default()).is_err());
|
||||
assert!(matches!(Session::default().execute(CMD, json!({"path":"none.csv"})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_parameters_and_damaged_timing_fail_before_writing() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
for p in [Value::Null, json!({}), json!({"path":0}), json!({"path":" "}), json!({"path":"bad\0.csv"})] {
|
||||
assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute(CMD, p))).unwrap().is_err());
|
||||
}
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
for (start, duration) in [(-1, 0), (0, -1), (i64::MAX, 1)] {
|
||||
Arc::make_mut(&mut s.project).sequence_mut(id).unwrap().markers = vec![marker(100, Tick(start), Tick(duration), "Bad")];
|
||||
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
|
||||
}
|
||||
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
|
||||
q.markers = vec![marker(100, Tick::ZERO, Tick::ZERO, "Bad")];
|
||||
q.settings.frame_rate.num = i64::MAX;
|
||||
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
|
||||
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
|
||||
q.settings.frame_rate = FrameRate::FPS_24;
|
||||
q.start_timecode = i64::MAX;
|
||||
q.markers[0].start = FrameRate::FPS_24.tick_of(1);
|
||||
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
|
||||
q_reset(&mut s, id);
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
s.execute(CMD, json!({"path":"recovered.csv"})).unwrap();
|
||||
}
|
||||
fn q_reset(s: &mut Session, id: filmcraft_project::ItemId) {
|
||||
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
|
||||
q.start_timecode = 0;
|
||||
q.markers.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_reports_and_write_errors_are_actionable() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
set_markers(&mut s, vec![marker(100, Tick::ZERO, Tick::ZERO, &"x".repeat(MAX_BYTES / 2))]);
|
||||
assert!(s.execute(CMD, json!({"path":"huge.csv"})).unwrap_err().to_string().contains("16 MiB"));
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
s.services = Arc::new(Files { fail: true, ..Default::default() });
|
||||
set_markers(&mut s, vec![]);
|
||||
s.saved_revision = s.revision;
|
||||
assert!(!s.is_dirty());
|
||||
let before = s.project.clone();
|
||||
assert!(s.execute(CMD, json!({"path":"denied.csv"})).unwrap_err().to_string().contains("denied.csv"));
|
||||
assert_eq!(*s.project, *before);
|
||||
assert!(!s.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_tracks_custom_labels_and_unusual_paths_export_without_dirtying() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let rate = FrameRate::FPS_23_976;
|
||||
s.edit_sequence("Locked review", |q, _, _| {
|
||||
q.settings.frame_rate = rate;
|
||||
for t in q.video_tracks.iter_mut().chain(&mut q.audio_tracks) {
|
||||
t.locked = true;
|
||||
}
|
||||
q.markers =
|
||||
vec![marker(101, Tick(rate.tick_of(24).0 + 1), Tick(1), "A, \"B\"\n字幕"), marker(100, Tick(rate.tick_of(24).0 + 1), Tick::ZERO, "Point")];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = "Review, \"urgent\"\n紫".into();
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
s.edit("Review name", |p, _| {
|
||||
p.items.get_mut(&id).unwrap().name = "Cut, \"night\"\n日本語".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.saved_revision = s.revision;
|
||||
let path = "review, \"notes\"\n日本語.csv";
|
||||
s.execute(CMD, json!({"path":path})).unwrap();
|
||||
assert_eq!(files.writes.lock().unwrap().last().unwrap().0, path);
|
||||
let csv = written(&files);
|
||||
assert!(csv.contains("\"Cut, \"\"night\"\"\n日本語\""));
|
||||
assert!(csv.contains("\"Review, \"\"urgent\"\"\n紫\""));
|
||||
assert!(csv.contains("\"A, \"\"B\"\"\n字幕\""));
|
||||
assert!(csv.find("\"100\"").unwrap() < csv.find("\"101\"").unwrap());
|
||||
assert!(csv.contains(&format!("\"00:00:01:00\",\"00:00:01:00\",\"0\",\"{}\",\"1\",\"24000/1001\"", rate.tick_of(24).0 + 1)));
|
||||
assert!(!s.is_dirty());
|
||||
}
|
||||
}
|
||||
|
|
@ -366,23 +366,34 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let after = u64_p(p, "after").ok_or_else(|| bad("masks.addVertex", "need `after`"))? as usize;
|
||||
let at = p.get("at").and_then(pt).ok_or_else(|| bad("masks.addVertex", "need `at`"))?;
|
||||
edit_mask(s, p, "Add Mask Vertex", move |m, t| {
|
||||
let n = m.path_at(t).vertices.len();
|
||||
// clamp against the length before any insertion, so the index stays valid in the grown path
|
||||
let i = (after + 1).min(n);
|
||||
if m.path.is_animated() && m.path.keyframes.len() > 1 {
|
||||
if n == 0 {
|
||||
return Err(bad("masks.addVertex", "the animated mask path has no vertices to insert after"));
|
||||
}
|
||||
// keep every keyframe interpolable: insert the vertex into all of them
|
||||
for k in &mut m.path.keyframes {
|
||||
if let ParamValue::Path(path) = &mut k.value {
|
||||
let i = (after + 1).min(path.vertices.len());
|
||||
let a = path.vertices[after.min(path.vertices.len() - 1)].p;
|
||||
let b = path.vertices[i % path.vertices.len()].p;
|
||||
path.vertices.insert(i, MaskVertex::corner(a.lerp(b, 0.5)));
|
||||
let len = path.vertices.len();
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
let at_i = i.min(len);
|
||||
let a = path.vertices[after.min(len - 1)].p;
|
||||
let b = path.vertices[at_i % len].p;
|
||||
path.vertices.insert(at_i, MaskVertex::corner(a.lerp(b, 0.5)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut path = m.path_at(t);
|
||||
let i = (after + 1).min(path.vertices.len());
|
||||
if m.path.keyframes.len() > 1 {
|
||||
path.vertices[i] = MaskVertex::corner(at);
|
||||
if let Some(v) = path.vertices.get_mut(i) {
|
||||
*v = MaskVertex::corner(at);
|
||||
}
|
||||
} else {
|
||||
path.vertices.insert(i, MaskVertex::corner(at));
|
||||
path.vertices.insert(i.min(path.vertices.len()), MaskVertex::corner(at));
|
||||
}
|
||||
set_path_at(m, t, path);
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -106,6 +106,39 @@ fn keyframed_mask_path_interpolates() {
|
|||
assert_eq!(a["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_vertex_on_animated_path_clamps_oversized_index_and_rejects_empty_path() {
|
||||
let (mut s, clip) = demo();
|
||||
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
|
||||
let rate = s.active_sequence().unwrap().settings.frame_rate;
|
||||
s.set_playhead(it.start);
|
||||
s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap();
|
||||
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "path"})).unwrap();
|
||||
s.set_playhead(it.start + rate.tick_of(10));
|
||||
s.execute("masks.translate", json!({"delta": [5, 0]})).unwrap();
|
||||
// an index past the last vertex appends instead of panicking
|
||||
s.execute("masks.addVertex", json!({"after": 100, "at": [1, 1]})).unwrap();
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(l["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 5);
|
||||
|
||||
// an empty animated path is a parameter error and leaves the mask alone
|
||||
s.set_playhead(it.start);
|
||||
s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap();
|
||||
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 1, "param": "path"})).unwrap();
|
||||
s.set_playhead(it.start + rate.tick_of(10));
|
||||
s.execute("masks.translate", json!({"mask": 1, "delta": [5, 0]})).unwrap();
|
||||
let e = s.execute("masks.addVertex", json!({"mask": 1, "after": 0, "at": [1, 1]})).unwrap_err().to_string();
|
||||
assert!(e.contains("no vertices"), "{e}");
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(l["masks"][1]["path"]["vertices"].as_array().unwrap().len(), 0);
|
||||
|
||||
// a static empty path still takes its first vertex
|
||||
s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap();
|
||||
s.execute("masks.addVertex", json!({"mask": 2, "after": 0, "at": [1, 1]})).unwrap();
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(l["masks"][2]["path"]["vertices"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_drag_is_one_undo_step_and_project_round_trips() {
|
||||
let (mut s, _) = demo();
|
||||
|
|
|
|||
|
|
@ -1048,7 +1048,7 @@ fn clip_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
// ------------------------------------------------------------------------------------- audio gain
|
||||
|
||||
/// Peak (dBFS, before clip gain) of each selected audio clip's source range.
|
||||
fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> {
|
||||
pub(crate) fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> {
|
||||
let Some(seq) = s.active_sequence() else { return Vec::new() };
|
||||
let sr = seq.settings.sample_rate.max(1);
|
||||
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
|
|
@ -1065,7 +1065,7 @@ fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraf
|
|||
let mut pos = s0;
|
||||
while pos < s0 + len {
|
||||
let n = (s0 + len - pos).min(sr as i64) as usize;
|
||||
if let Ok(b) = src.audio(pos, n, sr) {
|
||||
if let Ok(b) = src.audio_stream(it.audio_stream, pos, n, sr) {
|
||||
peak = b.peaks().into_iter().fold(peak, f32::max);
|
||||
}
|
||||
pos += n as i64;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ pub fn used_ranges(p: &Project, seqs: &[ItemId]) -> (BTreeMap<ItemId, (Tick, Tic
|
|||
Some(ItemKind::Subclip { parent, .. }) => {
|
||||
used.insert(ti.item);
|
||||
target = *parent;
|
||||
// Loaded projects can nest subclips; keep every link up to the media (bounded like `resolve_media`).
|
||||
let mut up = *parent;
|
||||
for _ in 0..16 {
|
||||
used.insert(up);
|
||||
match p.item(up).map(|i| &i.kind) {
|
||||
Some(ItemKind::Subclip { parent, .. }) => up = *parent,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => continue,
|
||||
|
|
|
|||
|
|
@ -101,3 +101,24 @@ fn consolidate_trims_to_used_ranges_with_handles() {
|
|||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_subclip_keeps_its_whole_parent_chain_used() {
|
||||
use filmcraft_project::{ItemKind, Label};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
let root = tmp_dir("pm-nested-subclip");
|
||||
let (s, items, _) = project(&root);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let mut p = (*s.project).clone();
|
||||
let range = TimeRange::new(Tick::ZERO, Tick(1000));
|
||||
let inner = p.add_item("Inner", Label::Iris, ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
|
||||
let outer = p.add_item("Outer", Label::Iris, ItemKind::Subclip { parent: inner, range, restrict_trims: false }, None);
|
||||
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
|
||||
for x in t.items.iter_mut().filter(|i| i.item == items[0]) {
|
||||
x.item = outer;
|
||||
}
|
||||
}
|
||||
let (_, used) = super::project_manager::used_ranges(&p, &[seq]);
|
||||
assert!(used.contains(&outer) && used.contains(&inner) && used.contains(&items[0]), "{used:?}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use filmcraft_project::{BinEntry, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind};
|
||||
use filmcraft_project::{BinEntry, BinId, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind};
|
||||
use filmcraft_time::{Tick, TimeDisplay, TimeRange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -699,14 +699,30 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
/// The selected items plus everything they need: sequence contents (recursively), subclip
|
||||
/// parents, multi-camera / merged-clip sources and graphic canvases.
|
||||
///
|
||||
/// Bins and items share one id space, so a named bin contributes every item it holds (and
|
||||
/// everything those items need), as `project.delete` does. The project's own top bin does not,
|
||||
/// since no command acts on it (#244), and only ids that name a real item end up in the result.
|
||||
pub fn closure(p: &Project, roots: &[ItemId]) -> BTreeSet<ItemId> {
|
||||
let mut keep: BTreeSet<ItemId> = BTreeSet::new();
|
||||
let mut todo: Vec<ItemId> = roots.to_vec();
|
||||
let mut seen_bins: BTreeSet<u64> = BTreeSet::new();
|
||||
while let Some(id) = todo.pop() {
|
||||
if !keep.insert(id) {
|
||||
let bin = BinId(id.0);
|
||||
if bin != p.root.id && p.root.find_bin(bin).is_some() {
|
||||
if seen_bins.insert(id.0)
|
||||
&& let Some(b) = p.root.find_bin(bin)
|
||||
{
|
||||
let mut items = Vec::new();
|
||||
b.all_items(&mut items);
|
||||
todo.extend(items);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(it) = p.item(id) else { continue };
|
||||
if !keep.insert(id) {
|
||||
continue;
|
||||
}
|
||||
match &it.kind {
|
||||
ItemKind::Subclip { parent, .. } => todo.push(*parent),
|
||||
ItemKind::Sequence(q) => {
|
||||
|
|
@ -754,6 +770,9 @@ fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Err(bad("file.exportSelectionProject", "select items in the Project panel"));
|
||||
}
|
||||
let keep = closure(&s.project, &roots);
|
||||
if keep.is_empty() {
|
||||
return Err(bad("file.exportSelectionProject", "the selection has no items to export"));
|
||||
}
|
||||
let mut sub = project_subset(&s.project, &keep);
|
||||
let name = std::path::Path::new(&path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| sub.name.clone());
|
||||
sub.name = name.clone();
|
||||
|
|
|
|||
|
|
@ -220,6 +220,59 @@ fn export_selection_project_and_ale() {
|
|||
assert!(doc.rows.iter().any(|r| r[0] == "Ambient_Score.wav" && r[1] == "A1A2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_selection_project_of_a_bin_exports_its_contents() {
|
||||
let mut s = demo();
|
||||
let (footage_bin, bin_items) = s
|
||||
.project
|
||||
.root
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
filmcraft_project::BinEntry::Bin(b) if b.name == "Footage" => {
|
||||
let mut items = Vec::new();
|
||||
b.all_items(&mut items);
|
||||
Some((b.id.0, items))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("Footage bin");
|
||||
assert_eq!(bin_items.len(), 6);
|
||||
|
||||
// Selecting the bin (its id, not its items) must export the bin's contents, not an empty project.
|
||||
s.execute("project.select", json!({"items": [footage_bin]})).unwrap();
|
||||
let dir = tmp_dir("export-bin");
|
||||
let path = dir.join("Footage.fcproj").to_string_lossy().to_string();
|
||||
let r = s.execute("file.exportSelectionProject", json!({"path": path})).unwrap();
|
||||
let p = filmcraft_format::decode(&std::fs::read(&path).unwrap()).unwrap().project;
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), p.items.len(), "reported items match the saved project");
|
||||
let reported: std::collections::BTreeSet<ItemId> = r["items"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(reported, bin_items.iter().copied().collect(), "the bin's items are the exported items");
|
||||
for n in ["Ocean_Sunset.mp4", "Neon_Loop.mov", "Misty_Forest.mp4"] {
|
||||
assert!(p.items.values().any(|i| i.name == n), "{n}");
|
||||
}
|
||||
assert!(!p.items.values().any(|i| i.name == "Ambient_Score.wav"), "the Audio bin stayed behind");
|
||||
let mut listed = Vec::new();
|
||||
p.root.all_items(&mut listed);
|
||||
assert!(p.items.keys().all(|k| listed.contains(k) || matches!(p.item(*k).unwrap().kind, ItemKind::Graphic { .. })));
|
||||
|
||||
// A mixed bin + item request must not report the bin id, which names no item in the output.
|
||||
let explicit = dir.join("Mixed.fcproj").to_string_lossy().to_string();
|
||||
let r = s.execute("file.exportSelectionProject", json!({"items": [footage_bin, bin_items[0].0], "path": explicit})).unwrap();
|
||||
let p = filmcraft_format::decode(&std::fs::read(&explicit).unwrap()).unwrap().project;
|
||||
for v in r["items"].as_array().unwrap() {
|
||||
let id = ItemId(v.as_u64().unwrap());
|
||||
assert!(p.item(id).is_some(), "reported item {id:?} is in the output");
|
||||
}
|
||||
|
||||
// An empty bin has nothing to export: an actionable error, no misleading empty file.
|
||||
let empty_bin = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap();
|
||||
s.execute("project.select", json!({"items": [empty_bin]})).unwrap();
|
||||
let empty_path = dir.join("Empty.fcproj").to_string_lossy().to_string();
|
||||
assert!(s.execute("file.exportSelectionProject", json!({"path": empty_path})).is_err());
|
||||
assert!(!std::path::Path::new(&empty_path).exists(), "no empty project written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_properties_settings_and_scratch_disks() {
|
||||
let dir = tmp_dir("props");
|
||||
|
|
|
|||
|
|
@ -407,12 +407,18 @@ pub fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(MediaRef::File { path }) => path,
|
||||
_ => return Err(bad("media.relink", format!("item {} is not file media", item.0))),
|
||||
};
|
||||
let c = check_candidate(s, item, &path, &o)?;
|
||||
let mut c = check_candidate(s, item, &path, &o)?;
|
||||
if !c.ok && !force {
|
||||
return Err(EngineError::Other(format!("{} doesn't match {}: {}", file_name(&path), file_name(&old), c.problems.join("; "))));
|
||||
}
|
||||
if c.source.is_none() && s.media.open_file(&path, &*s.services).is_err() {
|
||||
return Err(EngineError::Other(format!("{path} can't be opened")));
|
||||
if c.source.is_none() {
|
||||
// Forced past a failed check: the file was never opened for its properties, so do it now
|
||||
// and let the item take the new file's own info instead of keeping the old file's.
|
||||
let Ok(src) = s.media.open_file(&path, &*s.services) else {
|
||||
return Err(EngineError::Other(format!("{path} can't be opened")));
|
||||
};
|
||||
c.info = Some(src.info().clone());
|
||||
c.source = Some(src);
|
||||
}
|
||||
let mut plans = vec![Plan { item, cand: c }];
|
||||
if o.relink_others {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,22 @@ fn fingerprint_mismatch_is_refused_unless_forced() {
|
|||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_relink_takes_the_new_files_properties() {
|
||||
let root = tmp_dir("relink-forced-info");
|
||||
let (path, items) = saved_project(&root);
|
||||
std::fs::remove_dir_all(root.join("Media")).unwrap();
|
||||
// same name, different pictures and a different size
|
||||
let other = root.join("Other");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
make_movie(&other.join("a.mov"), DemoScene::Dunes, W * 2, H * 2, 12);
|
||||
let mut s = open(&path);
|
||||
s.execute("media.relink", json!({"item": items[0].0, "path": other.join("a.mov").to_string_lossy(), "force": true, "relinkOthers": false})).unwrap();
|
||||
let width = s.project.item(items[0]).and_then(|i| i.as_media()).and_then(|m| m.info.video.as_ref().map(|v| v.width));
|
||||
assert_eq!(width, Some(W * 2), "the item describes the replacement file");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_offline_shows_the_slate_until_relinked() {
|
||||
let root = tmp_dir("relink-offline");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! The Timeline's sequence tabs: closing the others, reordering, and what a project file keeps of
|
||||
//! them (the open tabs in order, the active one, and each sequence's zoom, scroll and track
|
||||
//! heights). Premiere's behaviour was observed in Premiere Pro 26.5.2.
|
||||
//! them (the open tabs in order, the active one, each sequence's zoom, scroll and track heights,
|
||||
//! and each sequence's playhead). Premiere's tab behaviour was observed in Premiere Pro 26.5.2.
|
||||
|
||||
use super::*;
|
||||
use filmcraft_project::SequenceView;
|
||||
use filmcraft_time::TICKS_PER_SECOND;
|
||||
use serde_json::json;
|
||||
|
||||
/// The demo project with two more sequences open: tabs [main, b, c], `c` active.
|
||||
|
|
@ -106,6 +107,52 @@ fn a_saved_project_reopens_with_its_tabs_and_their_views() {
|
|||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Each sequence reopens with its playhead where it was left: the project file keeps one playhead
|
||||
/// per sequence beside the tabs and views.
|
||||
#[test]
|
||||
fn each_sequence_reopens_with_its_playhead_where_it_was_left() {
|
||||
let (mut s, [main, b, c]) = three_tabs();
|
||||
let at = |s: &Session, seq: ItemId, secs: i64| s.project.sequence(seq).unwrap().settings.frame_rate.snap(Tick(secs * TICKS_PER_SECOND));
|
||||
s.execute("sequence.open", json!({"item": main.0})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 7})).unwrap();
|
||||
s.execute("sequence.open", json!({"item": b.0})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 2})).unwrap();
|
||||
let (main_at, b_at) = (at(&s, main, 7), at(&s, b, 2));
|
||||
assert_eq!(s.playhead(), b_at);
|
||||
assert_eq!(s.project_view().playheads.get(&c), None, "a playhead that never moved is not written");
|
||||
let dir = temp_dir("playheads");
|
||||
let path = dir.join("tabs.fcproj").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(t.state.active_sequence, Some(b));
|
||||
assert_eq!(t.playhead(), b_at, "the sequence that was shown is back where it was");
|
||||
t.execute("sequence.open", json!({"item": main.0})).unwrap();
|
||||
assert_eq!(t.playhead(), main_at, "and so is every other sequence");
|
||||
t.execute("sequence.open", json!({"item": c.0})).unwrap();
|
||||
assert_eq!(t.playhead(), Tick::ZERO);
|
||||
assert!(!t.is_dirty(), "a restored playhead is not an edit");
|
||||
|
||||
// with the preference off, nothing of the view comes back, playheads included
|
||||
let mut u = Session::default();
|
||||
u.prefs.timeline.restore_open_sequences = false;
|
||||
u.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(u.playhead(), Tick::ZERO);
|
||||
|
||||
// a file written before playheads were kept opens as it did then: every playhead at zero
|
||||
let mut doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(doc["view"]["playheads"].is_object());
|
||||
doc["view"].as_object_mut().unwrap().remove("playheads");
|
||||
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let mut w = Session::default();
|
||||
w.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(w.state.active_sequence, Some(b));
|
||||
assert_eq!(w.playhead(), Tick::ZERO);
|
||||
assert!(w.state.playheads.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The view in a project file is not trusted: ids of things that are not sequences, repeats and
|
||||
/// numbers out of range are dropped or brought into range, and a view that cannot be read at all
|
||||
/// does not stop the project from opening.
|
||||
|
|
@ -134,6 +181,13 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() {
|
|||
"999999": {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0},
|
||||
footage.0.to_string(): {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0},
|
||||
},
|
||||
"playheads": {
|
||||
c.0.to_string(): -5,
|
||||
b.0.to_string(): i64::MAX,
|
||||
main.0.to_string(): TICKS_PER_SECOND + 1,
|
||||
"999999": TICKS_PER_SECOND,
|
||||
footage.0.to_string(): TICKS_PER_SECOND,
|
||||
},
|
||||
}));
|
||||
assert_eq!(t.state.open_sequences, [c, b]);
|
||||
assert_eq!(t.state.active_sequence, Some(c), "an active tab that is not open: the first tab");
|
||||
|
|
@ -141,6 +195,16 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() {
|
|||
let v = t.state.timeline_views[&b];
|
||||
assert!(v.pps <= 1e5 && v.scroll == 0.0 && v.v_scroll <= 1e6 && v.a_scroll == 0.0);
|
||||
assert!(v.video_track_h >= 8.0 && v.audio_track_h <= 600.0);
|
||||
// playheads: only sequences keep one, never before zero or past the bound, always on a frame
|
||||
let rate = |seq: ItemId| t.project.sequence(seq).unwrap().settings.frame_rate;
|
||||
assert_eq!(t.state.playheads.keys().copied().collect::<Vec<_>>(), {
|
||||
let mut ids = vec![main, b, c];
|
||||
ids.sort();
|
||||
ids
|
||||
});
|
||||
assert_eq!(t.state.playheads[&c], Tick::ZERO);
|
||||
assert_eq!(t.state.playheads[&b], rate(b).snap(filmcraft_project::ProjectView::MAX_PLAYHEAD));
|
||||
assert_eq!(t.state.playheads[&main], rate(main).snap(Tick(TICKS_PER_SECOND + 1)));
|
||||
|
||||
// not a view at all: the project opens as it does without one
|
||||
for junk in [json!("tabs"), json!([1, 2, 3]), json!({"open_sequences": "all"}), json!({"sequences": {"x": 1}}), json!(null)] {
|
||||
|
|
@ -159,6 +223,7 @@ fn the_view_of_a_deleted_sequence_is_forgotten() {
|
|||
s.execute("project.delete", json!({"items": [b.0]})).unwrap();
|
||||
assert_eq!(s.state.timeline_views.keys().copied().collect::<Vec<_>>(), [main, c]);
|
||||
assert_eq!(s.state.open_sequences, [main, c]);
|
||||
// the saved view never names it either
|
||||
// the saved view never names it either, nor keeps its playhead
|
||||
assert_eq!(s.project_view().open_sequences, [main, c]);
|
||||
assert!(!s.project_view().playheads.contains_key(&b));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
//! | `sequence.goToNextGapInTrack` / `sequence.goToPrevGapInTrack` | Sequence ▸ Go to Gap ▸ Next / Previous in Track | |
|
||||
//! | `sequence.selectionFollowsPlayhead` | Sequence ▸ Selection Follows Playhead | |
|
||||
//! | `sequence.showThroughEdits` | Sequence ▸ Show Through Edits | |
|
||||
//! | `sequence.joinThroughEdits` | (clip context menu) Join Through Edits | |
|
||||
//! | `sequence.joinThroughEdits` | (clip and edit point context menus) Join Through Edits | |
|
||||
//! | `sequence.throughEdits` | query: the through edits of the active sequence | |
|
||||
//! | `sequence.makeSubsequence` | Sequence ▸ Make Subsequence | Shift+U |
|
||||
//! | `sequence.deleteTracks` | Sequence ▸ Delete Tracks… | |
|
||||
|
|
@ -103,7 +103,15 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
|
|||
s.state.show_through_edits = bool_p(p, "on").unwrap_or(!s.state.show_through_edits);
|
||||
Ok(json!({"showThroughEdits": s.state.show_through_edits}))
|
||||
}),
|
||||
spec("sequence.joinThroughEdits", "Join Through Edits", &[], None, r#"{"clips":[id]?,"all":bool?}"#, has_seq, join_through_edits),
|
||||
spec(
|
||||
"sequence.joinThroughEdits",
|
||||
"Join Through Edits",
|
||||
&[],
|
||||
None,
|
||||
r#"{"clips":[id]?,"cut":[id,id]?,"all":bool?}"#,
|
||||
has_seq,
|
||||
join_through_edits,
|
||||
),
|
||||
query("sequence.throughEdits", "List Through Edits", "{}", |s, _| {
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
Ok(Value::Array(
|
||||
|
|
@ -326,6 +334,9 @@ fn go_to_gap(s: &mut Session, p: &Value, next: bool, in_track: bool) -> Result<V
|
|||
// ---------- through edits ----------
|
||||
|
||||
fn join_through_edits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(cut) = p.get("cut") {
|
||||
return join_through_edit_at(s, cut);
|
||||
}
|
||||
let all = bool_p(p, "all").unwrap_or(false) || (p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty());
|
||||
let only = if all { Vec::new() } else { with_links(s, &clips_p(s, p)) };
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
|
|
@ -343,6 +354,26 @@ fn join_through_edits(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"joined": n}))
|
||||
}
|
||||
|
||||
/// `sequence.joinThroughEdits {cut: [left, right]}`: join just that one cut (the edit point menu,
|
||||
/// #219), with the cuts between the same pieces' linked partners when linked selection is on.
|
||||
fn join_through_edit_at(s: &mut Session, cut: &Value) -> Result<Value> {
|
||||
let ids: Vec<ClipId> = cut.as_array().map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
|
||||
let [left, right] = ids[..] else {
|
||||
return Err(EngineError::Other("sequence.joinThroughEdits: `cut` needs two clip ids, [left, right]".into()));
|
||||
};
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if !edit::through::through_edits(q).iter().any(|e| e.left == left && e.right == right) {
|
||||
return Err(EngineError::Other("that cut is not a through edit".into()));
|
||||
}
|
||||
let (lefts, rights) = (with_links(s, &[left]), with_links(s, &[right]));
|
||||
let n = s.edit_sequence("Join Through Edits", |q, _, st| {
|
||||
let n = edit::through::join_through_edits_where(q, |a, b| lefts.contains(&a) && rights.contains(&b));
|
||||
st.selection.retain(|c| q.find_item(*c).is_some());
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"joined": n}))
|
||||
}
|
||||
|
||||
// ---------- subsequence ----------
|
||||
|
||||
/// Sequence ▸ Make Subsequence: a new sequence (same settings and track layout) holding copies of the
|
||||
|
|
|
|||
|
|
@ -142,6 +142,41 @@ fn through_edits_show_and_join() {
|
|||
assert_eq!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().len(), n - 2);
|
||||
}
|
||||
|
||||
/// `cut: [left, right]` (the timeline's edit point menu, #219) joins exactly that cut and its
|
||||
/// linked partner, even when one of its pieces is also part of another through edit.
|
||||
#[test]
|
||||
fn join_one_through_edit_by_its_cut() {
|
||||
let mut s = demo();
|
||||
let v1 = s.active_sequence().unwrap().video_tracks[0].id.0;
|
||||
for frame in [30, 40] {
|
||||
s.execute("playhead.set", json!({"frame": frame})).unwrap();
|
||||
s.execute("sequence.addEditAllTracks", json!({})).unwrap();
|
||||
}
|
||||
let te = s.execute("sequence.throughEdits", json!({})).unwrap();
|
||||
let on_v1: Vec<&Value> = te.as_array().unwrap().iter().filter(|e| e["track"] == json!(v1)).collect();
|
||||
assert_eq!(on_v1.len(), 2);
|
||||
// the second cut: its left piece is also the right piece of the first one
|
||||
let (second, first) = (on_v1[1], on_v1[0]);
|
||||
assert_eq!(second["left"], first["right"]);
|
||||
let n = te.as_array().unwrap().len();
|
||||
let r = s.execute("sequence.joinThroughEdits", json!({"cut": [second["left"], second["right"]]})).unwrap();
|
||||
assert_eq!(r["joined"], json!(2), "V1 + linked A1");
|
||||
let te = s.execute("sequence.throughEdits", json!({})).unwrap();
|
||||
assert_eq!(te.as_array().unwrap().len(), n - 2);
|
||||
assert!(te.as_array().unwrap().iter().any(|e| e["left"] == first["left"] && e["right"] == first["right"]), "the first cut stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_by_cut_rejects_hostile_parameters() {
|
||||
let mut s = demo();
|
||||
let items = s.active_sequence().unwrap().video_tracks[0].items.clone();
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
for cut in [json!([]), json!([1]), json!([1, 2, 3]), json!("x"), json!([-1, 2]), json!([u64::MAX, 0]), json!([items[0].id.0, items[1].id.0])] {
|
||||
assert!(s.execute("sequence.joinThroughEdits", json!({"cut": cut})).is_err(), "{cut}");
|
||||
}
|
||||
assert_eq!(s.active_sequence().unwrap(), &before, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_subsequence_from_in_out_and_from_selection() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -35,6 +35,50 @@ fn demo_project_is_valid_and_renders() {
|
|||
assert!(img.px.chunks(4).any(|p| p[3] > 0.9));
|
||||
}
|
||||
|
||||
/// Ordinary and configured multi-frame offsets remain valid.
|
||||
#[test]
|
||||
fn frame_steps_in_new_custom_rate_sequences_move_in_the_requested_direction() {
|
||||
for fps in [31.0, 23.98, 24.999] {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"fps": fps})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
s.execute("playhead.set", json!({"frame": 10})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), 10);
|
||||
for frame in 11..=42 {
|
||||
s.execute("playhead.stepForward", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), frame);
|
||||
}
|
||||
for frame in (10..42).rev() {
|
||||
s.execute("playhead.stepBack", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), frame);
|
||||
}
|
||||
s.execute("prefs.set", json!({"key": "playback.stepManyFrames", "value": 12})).unwrap();
|
||||
s.execute("playhead.stepForward5", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), 22);
|
||||
s.execute("playhead.stepBack5", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), 10);
|
||||
s.execute("playhead.step", json!({"frames": -20})).unwrap();
|
||||
s.execute("playhead.stepBack", json!({})).unwrap();
|
||||
assert_eq!(s.playhead(), Tick::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_frame_steps_are_errors_and_leave_the_playhead_unchanged() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"fps": 31.0})).unwrap();
|
||||
for start in [10, 0] {
|
||||
s.execute("playhead.set", json!({"frame": start})).unwrap();
|
||||
let before = s.playhead();
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("playhead.step", json!({"frames": i64::MAX}))));
|
||||
assert!(result.is_ok(), "an extreme frame offset must not panic");
|
||||
assert!(matches!(result.unwrap(), Err(EngineError::BadParams { .. })));
|
||||
assert_eq!(s.playhead(), before);
|
||||
}
|
||||
s.execute("playhead.step", json!({"frames": i64::MIN})).unwrap();
|
||||
assert_eq!(s.playhead(), Tick::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_edit_undo_redo() {
|
||||
let mut s = demo();
|
||||
|
|
@ -282,6 +326,51 @@ fn add_keyframe_toggles_the_keyframe_at_the_playhead() {
|
|||
assert_eq!(scale(&s), (0, 100.0));
|
||||
}
|
||||
|
||||
/// Dragging the Volume line or one of its keyframes in the timeline (#223) sends a keyframe edit
|
||||
/// every frame; `merge` keeps the whole drag one undo step and `begin` starts the next one.
|
||||
#[test]
|
||||
fn keyframe_drags_are_one_undo_step() {
|
||||
let mut s = demo();
|
||||
let c = s.active_sequence().unwrap().audio_tracks[0].items[0].id.0;
|
||||
let level = |s: &Session| -> Vec<(i64, f64)> {
|
||||
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
|
||||
it.effect("volume").unwrap().params["level"].keyframes.iter().map(|k| (k.time.0, k.value.as_f64().unwrap())).collect()
|
||||
};
|
||||
let key = json!({"clip": c, "effect": "volume", "param": "level"});
|
||||
for sec in [0.5, 1.5] {
|
||||
s.execute("playhead.set", json!({"seconds": sec})).unwrap();
|
||||
s.execute("effects.addKeyframe", key.clone()).unwrap();
|
||||
}
|
||||
let before = level(&s);
|
||||
let [(t0, _), (t1, _)] = before[..] else { panic!("two keyframes: {before:?}") };
|
||||
|
||||
// the line between them: both keyframes move together, frame after frame
|
||||
for (i, v) in [-1.0, -2.0, -3.0].into_iter().enumerate() {
|
||||
for t in [t0, t1] {
|
||||
let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t, "value": v, "merge": true, "begin": i == 0 && t == t0});
|
||||
s.execute("effects.setKeyframe", p).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(level(&s), [(t0, -3.0), (t1, -3.0)]);
|
||||
s.undo();
|
||||
assert_eq!(level(&s), before);
|
||||
|
||||
// one keyframe: time and value in one command
|
||||
let mut at = t1;
|
||||
for (i, d) in [1000, 2000, 3000].into_iter().enumerate() {
|
||||
let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": at, "to": t1 + d, "value": -6.0, "merge": true, "begin": i == 0});
|
||||
s.execute("effects.moveKeyframe", p).unwrap();
|
||||
at = t1 + d;
|
||||
}
|
||||
assert_eq!(level(&s), [before[0], (t1 + 3000, -6.0)]);
|
||||
s.undo();
|
||||
assert_eq!(level(&s), before);
|
||||
|
||||
let bad = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t0, "to": t0 + 1, "value": "loud"});
|
||||
assert!(s.execute("effects.moveKeyframe", bad).is_err());
|
||||
assert_eq!(level(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_and_markers() {
|
||||
let mut s = demo();
|
||||
|
|
@ -641,3 +730,37 @@ fn dragging_an_effect_parameter_is_one_undo_step() {
|
|||
s.undo();
|
||||
assert_eq!(opacity(&s), start, "and then the whole first one");
|
||||
}
|
||||
|
||||
/// #484: Enable flips each selected clip on its own, as in Premiere. With one enabled and one
|
||||
/// disabled clip selected it swaps them, instead of first making both the same.
|
||||
#[test]
|
||||
fn enable_flips_each_selected_clip() {
|
||||
let mut s = demo();
|
||||
let enabled = |s: &Session, c: u64| s.active_sequence().unwrap().find_item(ClipId(c)).unwrap().1.enabled;
|
||||
let partner = |s: &Session, c: u64| {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let link = q.find_item(ClipId(c)).unwrap().1.link?;
|
||||
q.all_tracks().flat_map(|t| t.items.iter()).find(|i| i.link == Some(link) && i.id != ClipId(c)).map(|i| i.id.0)
|
||||
};
|
||||
let (a, b) = (v1(&s)[0].0, v1(&s)[1].0);
|
||||
s.execute("clip.enable", json!({"clips": [a]})).unwrap();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true));
|
||||
// mixed selection: each flips
|
||||
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (true, false));
|
||||
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true));
|
||||
// a clip named twice still flips once, and one press is one undo step
|
||||
s.execute("clip.enable", json!({"clips": [a, a]})).unwrap();
|
||||
assert!(enabled(&s, a));
|
||||
s.undo();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true));
|
||||
// with linked selection on, a clip's linked audio flips with it
|
||||
if let Some(au) = partner(&s, a) {
|
||||
assert_eq!(enabled(&s, au), enabled(&s, a));
|
||||
}
|
||||
// a uniform selection still toggles as before
|
||||
s.execute("clip.enable", json!({"clips": [b]})).unwrap();
|
||||
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ pub fn toggle_type(s: &mut Session) -> Result<Value> {
|
|||
|
||||
/// Trim every selected edit point by `delta` (edit point movement; positive = later).
|
||||
fn trim_all(s: &mut Session, delta: Tick) -> Result<Value> {
|
||||
let applied = trim_all_raw(s, delta)?;
|
||||
let applied = trim_all_raw(s, &|_, _| Some(delta))?;
|
||||
let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default();
|
||||
add_shift(s, first);
|
||||
Ok(json!({"applied": applied}))
|
||||
|
|
@ -193,7 +193,8 @@ fn reset_shift(s: &mut Session) {
|
|||
s.state.trim_shift = TrimShift::default();
|
||||
}
|
||||
|
||||
fn trim_all_raw(s: &mut Session, delta: Tick) -> Result<Vec<Value>> {
|
||||
/// Trim every selected edit point; `delta_for` gives each point's own movement, read from the sequence as it is when that point is reached.
|
||||
fn trim_all_raw(s: &mut Session, delta_for: &dyn Fn(&Sequence, &EditPoint) -> Option<Tick>) -> Result<Vec<Value>> {
|
||||
if s.state.edit_points.is_empty() {
|
||||
return Err(EngineError::Other("no edit points selected".into()));
|
||||
}
|
||||
|
|
@ -201,6 +202,7 @@ fn trim_all_raw(s: &mut Session, delta: Tick) -> Result<Vec<Value>> {
|
|||
let mut applied = Vec::new();
|
||||
for ep in &pts {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let Some(delta) = delta_for(seq, ep) else { continue };
|
||||
let r = match ep.kind {
|
||||
TrimKind::Roll => {
|
||||
let Some((l, r)) = roll_pair(seq, ep) else { continue };
|
||||
|
|
@ -226,9 +228,10 @@ pub fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// `trim.extendToPlayhead`: move each selected edit point to the playhead.
|
||||
pub fn extend_to_playhead(s: &mut Session) -> Result<Value> {
|
||||
let ph = s.playhead();
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let first = s.state.edit_points.first().and_then(|e| edit_time(seq, e)).ok_or_else(|| EngineError::Other("no edit points selected".into()))?;
|
||||
trim_all(s, ph - first)
|
||||
let applied = trim_all_raw(s, &|seq, e| edit_time(seq, e).map(|t| ph - t))?;
|
||||
let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default();
|
||||
add_shift(s, first);
|
||||
Ok(json!({"applied": applied}))
|
||||
}
|
||||
|
||||
/// `trim.toPlayhead {side: "previous"|"next", ripple}` (Q/W, ⌥Q/⌥W): remove the material between
|
||||
|
|
@ -502,14 +505,14 @@ fn apply_dynamic(s: &mut Session, offset: Tick) -> Result<Tick> {
|
|||
let redo = std::mem::take(&mut s.history.redo);
|
||||
s.history.limit = usize::MAX;
|
||||
s.project = d.before.clone();
|
||||
let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, offset) };
|
||||
let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, &|_, _| Some(offset)) };
|
||||
let applied = match r {
|
||||
Ok(a) => a.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(),
|
||||
Err(_) => {
|
||||
// refused outright (e.g. it would break sync): stay where we were
|
||||
s.project = d.before.clone();
|
||||
if d.offset != Tick::ZERO {
|
||||
let _ = trim_all_raw(s, d.offset);
|
||||
let _ = trim_all_raw(s, &|_, _| Some(d.offset));
|
||||
}
|
||||
d.offset
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,3 +210,29 @@ fn trim_monitor_sides_and_shift_counters() {
|
|||
let seq = s.execute("sequence.inspect", json!({})).unwrap();
|
||||
assert!(!seq["video"][0]["transitions"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() {
|
||||
let mut s = demo();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
let q = s.active_sequence().unwrap();
|
||||
// a clip on V1 and the overlay on V2 that overlap in time but end at different frames
|
||||
let mut found = None;
|
||||
for a in &q.video_tracks[0].items {
|
||||
for b in &q.video_tracks[1].items {
|
||||
let (lo, hi) = (a.start.max(b.start), a.end().min(b.end()));
|
||||
if found.is_none() && a.end() != b.end() && hi - lo > rate.tick_of(2) {
|
||||
found = Some((a.id, b.id, lo + rate.tick_of(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (a, b, ph) = found.expect("demo has overlapping clips on V1 and V2 with different ends");
|
||||
s.execute("playhead.set", json!({"time": ph.0})).unwrap();
|
||||
s.execute("trim.selectEditPoint", json!({"clip": a.0, "edge": "out", "kind": "trim"})).unwrap();
|
||||
s.execute("trim.selectEditPoint", json!({"clip": b.0, "edge": "out", "kind": "trim", "add": true})).unwrap();
|
||||
s.execute("trim.extendToPlayhead", json!({})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(q.find_item(a).unwrap().1.end(), ph);
|
||||
assert_eq!(q.find_item(b).unwrap().1.end(), ph);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,38 @@ fn demo() -> Session {
|
|||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_preview_is_read_only_and_matches_apply_and_undo() {
|
||||
use crate::commands::preview_transition;
|
||||
use filmcraft_project::TrackKind;
|
||||
for (kind, effect) in [(TrackKind::Video, "cross_dissolve"), (TrackKind::Audio, "constant_power")] {
|
||||
let mut s = demo();
|
||||
let clips: Vec<_> = s.active_sequence().unwrap().tracks(kind)[0].items.iter().map(|c| c.id).collect();
|
||||
for clip in clips {
|
||||
for edge in ["in", "out"] {
|
||||
let params = json!({"effect": effect, "clip": clip.0, "edge": edge, "frames": 7});
|
||||
let before = s.project.to_json();
|
||||
let revision = s.revision;
|
||||
let (track, planned) = preview_transition(&s, ¶ms, kind).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.revision, revision);
|
||||
let result = s.execute("effects.apply", params).unwrap();
|
||||
let applied =
|
||||
s.active_sequence().unwrap().track(track).unwrap().transitions.iter().find(|t| Some(t.id.0) == result["transition"].as_u64()).unwrap();
|
||||
assert_eq!((applied.start, applied.duration, applied.from, applied.to), (planned.start, planned.duration, planned.from, planned.to));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(preview_transition(&s, &json!({"effect": effect, "clip": u64::MAX}), kind).is_err());
|
||||
let track = s.active_sequence().unwrap().tracks(kind)[0].id;
|
||||
std::sync::Arc::make_mut(&mut s.project).sequence_mut(s.state.active_sequence.unwrap()).unwrap().track_mut(track).unwrap().locked = true;
|
||||
assert!(preview_transition(&s, &json!({"effect": effect, "clip": s.active_sequence().unwrap().track(track).unwrap().items[0].id.0}), kind).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn first_v1_clip(s: &Session) -> u64 {
|
||||
s.active_sequence().unwrap().video_tracks[0].items[0].id.0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -418,6 +418,7 @@ mod tests {
|
|||
open_sequences: vec![seq],
|
||||
active_sequence: Some(seq),
|
||||
sequences: [(seq, SequenceView { pps: 80.0, scroll: 1.5, v_scroll: 0.0, a_scroll: 4.0, video_track_h: 60.0, audio_track_h: 56.0 })].into(),
|
||||
playheads: [(seq, filmcraft_time::Tick(3 * filmcraft_time::TICKS_PER_SECOND))].into(),
|
||||
};
|
||||
for pretty in [true, false] {
|
||||
let l = decode(&encode_with_view(&p, Some(&view), pretty)).unwrap();
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ pub(crate) mod pid {
|
|||
// Root
|
||||
pub const ROOT_META_DICTIONARY: u16 = 0x0001;
|
||||
pub const ROOT_HEADER: u16 = 0x0002;
|
||||
// MetaDictionary and MetaDefinition
|
||||
pub const META_CLASS_DEFINITIONS: u16 = 0x0003;
|
||||
pub const META_TYPE_DEFINITIONS: u16 = 0x0004;
|
||||
pub const META_IDENTIFICATION: u16 = 0x0005;
|
||||
// Header
|
||||
pub const BYTE_ORDER: u16 = 0x3B01;
|
||||
pub const LAST_MODIFIED: u16 = 0x3B02;
|
||||
|
|
|
|||
|
|
@ -172,6 +172,8 @@ fn prop_name(pid: u16) -> &'static str {
|
|||
match pid {
|
||||
0x0001 => "MetaDictionary",
|
||||
0x0002 => "Header",
|
||||
0x0003 => "ClassDefinitions",
|
||||
0x0004 => "TypeDefinitions",
|
||||
0x3B03 => "Content",
|
||||
0x3B04 => "Dictionary",
|
||||
0x3B06 => "IdentificationList",
|
||||
|
|
@ -200,6 +202,16 @@ fn prop_name(pid: u16) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// The key size of a strong reference set keyed by `key_pid`, for a set with no entry to take it
|
||||
/// from: AUIDs (definitions) are 16 bytes, MobIDs 32.
|
||||
fn set_key_size(key_pid: u16) -> usize {
|
||||
match key_pid {
|
||||
0x0005 | 0x1B01 => 16,
|
||||
0x4401 | 0x2701 => 32,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_name(pid: u16) -> String {
|
||||
let suffix = format!("-{pid:x}");
|
||||
let name: String = prop_name(pid).chars().take(22 - suffix.len()).collect();
|
||||
|
|
@ -269,7 +281,7 @@ impl W {
|
|||
index.extend_from_slice(&(items.len() as u32).to_le_bytes()); // first free key
|
||||
index.extend_from_slice(&u32::MAX.to_le_bytes()); // last free key
|
||||
let set_key = if let Value::StrongSet(_, k) = v { Some(*k) } else { None };
|
||||
let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or(0, <[u8]>::len)).unwrap_or(0);
|
||||
let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or_else(|| set_key_size(k), <[u8]>::len)).unwrap_or(0);
|
||||
if let Some(k) = set_key {
|
||||
index.extend_from_slice(&k.to_le_bytes());
|
||||
index.push(key_size as u8);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,32 @@ fn keys(dict: &Obj, p: u16) -> HashSet<Vec<u8>> {
|
|||
dict.objs(p).iter().filter_map(|d| d.data(pid::IDENTIFICATION).map(<[u8]>::to_vec)).collect()
|
||||
}
|
||||
|
||||
/// #323: readers that load the meta-dictionary (pyaaf2) need its ClassDefinitions and
|
||||
/// TypeDefinitions sets. They are empty: everything written uses the baseline classes and types
|
||||
/// those readers already know.
|
||||
#[test]
|
||||
fn the_meta_dictionary_has_its_class_and_type_definition_sets() {
|
||||
let (p, s) = project();
|
||||
for small in [false, true] {
|
||||
let (bytes, _) = export(&p, s, &AafOptions { small_sectors: small, ..Default::default() }).unwrap();
|
||||
let cf = filmcraft_cfb::CompoundFile::open(&bytes).unwrap();
|
||||
assert_eq!(cf.entries[cf.find("MetaDictionary-1").unwrap()].clsid, ids::META_DICTIONARY);
|
||||
for name in ["ClassDefinitions-3", "TypeDefinitions-4"] {
|
||||
// u32 count, u32 first free key, u32 last free key, u16 key pid, u8 key size
|
||||
let index = cf.read_path(&format!("MetaDictionary-1/{name} index")).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(index.len(), 15, "{name}: an empty set");
|
||||
assert_eq!(index[0..4], 0u32.to_le_bytes(), "{name}: no entries");
|
||||
assert_eq!(u16::from_le_bytes([index[12], index[13]]), pid::META_IDENTIFICATION, "{name}: keyed by the definition's AUID");
|
||||
assert_eq!(index[14], 16, "{name}: 16-byte keys");
|
||||
}
|
||||
let root = store::read(&bytes).unwrap();
|
||||
let meta = root.strong(pid::ROOT_META_DICTIONARY).unwrap();
|
||||
for set in [pid::META_CLASS_DEFINITIONS, pid::META_TYPE_DEFINITIONS] {
|
||||
assert!(matches!(meta.get(set), Some(Value::StrongSet(items, k)) if items.is_empty() && *k == pid::META_IDENTIFICATION), "{set:#06x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_files_have_the_required_structure() {
|
||||
let (p, s) = project();
|
||||
|
|
|
|||
|
|
@ -349,9 +349,13 @@ pub(crate) fn write(doc: &Document, version: filmcraft_cfb::Version, report: &mu
|
|||
.with(pid::CONTENT, Value::Strong(Box::new(content)))
|
||||
.with(pid::DICTIONARY, Value::Strong(Box::new(dict)))
|
||||
.with(pid::IDENTIFICATION_LIST, Value::StrongVec(vec![ident]));
|
||||
let root = Obj::new(ROOT)
|
||||
.with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(Obj::new(META_DICTIONARY))))
|
||||
.with(pid::ROOT_HEADER, Value::Strong(Box::new(header)));
|
||||
// The meta-dictionary holds only extensions to the baseline object model, and everything written
|
||||
// here is baseline, so its class and type definition sets are empty; readers that load them
|
||||
// (pyaaf2) still need the sets to be there (#323).
|
||||
let meta = Obj::new(META_DICTIONARY)
|
||||
.with(pid::META_CLASS_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION))
|
||||
.with(pid::META_TYPE_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION));
|
||||
let root = Obj::new(ROOT).with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(meta))).with(pid::ROOT_HEADER, Value::Strong(Box::new(header)));
|
||||
store::write(&root, version).map_err(Error::Other)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ pub fn file_url_to_path(url: &str) -> String {
|
|||
/// Convert an absolute path to a `file://` URL (`localhost` form for FCP7 XML).
|
||||
pub fn path_to_file_url(path: &str, localhost: bool) -> String {
|
||||
let p = path.replace('\\', "/");
|
||||
// Windows verbatim paths (`\\?\C:\x`, `\\?\UNC\server\share`, as `canonicalize` returns them)
|
||||
// are not network shares: drop the prefix so `?` doesn't become the URL's host.
|
||||
let p = if let Some(unc) = p.strip_prefix("//?/UNC/") {
|
||||
format!("//{unc}")
|
||||
} else if let Some(local) = p.strip_prefix("//?/") {
|
||||
local.to_string()
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let host = if localhost { "localhost" } else { "" };
|
||||
if is_windows_abs(&p) {
|
||||
format!("file://{host}/{}", percent_encode_path(&p))
|
||||
|
|
@ -675,6 +684,8 @@ mod tests {
|
|||
assert_eq!(file_url_to_path("/plain/path.mov"), "/plain/path.mov");
|
||||
assert_eq!(path_to_file_url("/Users/me/My Clip.mov", true), "file://localhost/Users/me/My%20Clip.mov");
|
||||
assert_eq!(path_to_file_url("C:\\Media\\a b.mxf", false), "file:///C:/Media/a%20b.mxf");
|
||||
assert_eq!(path_to_file_url("\\\\?\\C:\\Media\\a.mp4", true), "file://localhost/C:/Media/a.mp4");
|
||||
assert_eq!(path_to_file_url("\\\\?\\UNC\\srv\\share\\a.mp4", false), "file://srv/share/a.mp4");
|
||||
for p in ["/a/b c/ü.mov", "C:/x/y.mp4", "/r/%/#?.mov"] {
|
||||
assert_eq!(file_url_to_path(&path_to_file_url(p, true)), p);
|
||||
assert_eq!(file_url_to_path(&path_to_file_url(p, false)), p);
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ struct Exporter<'a> {
|
|||
p: &'a Project,
|
||||
media: &'a MediaOptions,
|
||||
doc: Document,
|
||||
memo: HashMap<(String, CKind, Option<u32>), usize>,
|
||||
memo: HashMap<(String, CKind, usize, Option<u32>), usize>,
|
||||
report: &'a mut Report,
|
||||
seq_rate: FrameRate,
|
||||
nests: Nests,
|
||||
|
|
@ -307,7 +307,7 @@ impl Exporter<'_> {
|
|||
if ckind == CKind::Sound
|
||||
&& let Some(e) = self.media.essence_for(clip_key, channel).cloned()
|
||||
{
|
||||
let memo_key = (format!("clip:{}", c.id.0), ckind, channel);
|
||||
let memo_key = (format!("clip:{}", c.id.0), ckind, 0, channel);
|
||||
if let Some(&i) = self.memo.get(&memo_key) {
|
||||
return Some(i);
|
||||
}
|
||||
|
|
@ -346,7 +346,7 @@ impl Exporter<'_> {
|
|||
self.report.warn(format!("the sound of the nested sequence \"{name}\" was not rendered (left as a gap)"));
|
||||
return None;
|
||||
}
|
||||
let memo_key = (format!("seq:{}", item.0), ckind, channel);
|
||||
let memo_key = (format!("seq:{}", item.0), ckind, 0, channel);
|
||||
if let Some(&i) = self.memo.get(&memo_key) {
|
||||
return Some(i);
|
||||
}
|
||||
|
|
@ -429,21 +429,29 @@ impl Exporter<'_> {
|
|||
// the source channel this slot plays when broken out to mono
|
||||
let channel = sub.map(|s| c.source_channels.get(s as usize).map(|&x| x as u32).unwrap_or(s));
|
||||
let clip_key = EssenceKey::Clip(c.id);
|
||||
let media_key = EssenceKey::Media(item);
|
||||
let media_key = EssenceKey::media(item, c.audio_stream);
|
||||
let ess = (ckind == CKind::Sound)
|
||||
.then(|| self.media.essence_for(clip_key, channel).or_else(|| self.media.essence_for(media_key, channel)))
|
||||
.flatten()
|
||||
.cloned();
|
||||
let key = match &ess {
|
||||
Some(e) if e.key == clip_key => format!("clip:{}", c.id.0),
|
||||
_ if ckind == CKind::Sound && c.audio_stream > 0 => format!("item:{}:stream:{}", item.0, c.audio_stream),
|
||||
_ => format!("item:{}", item.0),
|
||||
};
|
||||
let memo_key = (key.clone(), ckind, channel);
|
||||
// Original linked file locators cannot name a container stream. Leave an explicit
|
||||
// unsupported-feature report rather than reference its first audio stream.
|
||||
if ckind == CKind::Sound && c.audio_stream > 0 && ess.is_none() {
|
||||
self.report.warn("selected container audio streams require separate or embedded audio essence (left as gaps)");
|
||||
return None;
|
||||
}
|
||||
let stream = if ckind == CKind::Sound { c.audio_stream } else { 0 };
|
||||
let memo_key = (key.clone(), ckind, stream, channel);
|
||||
if let Some(&i) = self.memo.get(&memo_key) {
|
||||
return Some(i);
|
||||
}
|
||||
let video = m.info.video.as_ref();
|
||||
let audio = m.info.audio();
|
||||
let audio = m.info.audio_streams.get(c.audio_stream);
|
||||
let still = m.info.kind == MediaKind::Still;
|
||||
match ckind {
|
||||
CKind::Picture if video.is_none() && !still => return None,
|
||||
|
|
|
|||
|
|
@ -19,15 +19,25 @@ use serde::{Deserialize, Serialize};
|
|||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum EssenceKey {
|
||||
Media(ItemId),
|
||||
MediaStream { item: ItemId, stream: usize },
|
||||
Clip(ClipId),
|
||||
}
|
||||
|
||||
impl EssenceKey {
|
||||
pub fn media(item: ItemId, stream: usize) -> Self {
|
||||
if stream == 0 { Self::Media(item) } else { Self::MediaStream { item, stream } }
|
||||
}
|
||||
}
|
||||
|
||||
/// One range of source audio an export references.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AudioNeed {
|
||||
pub key: EssenceKey,
|
||||
/// The media item (for a clip key: the clip's media).
|
||||
pub item: ItemId,
|
||||
/// Selected container stream; zero for legacy and sequence sources.
|
||||
#[serde(default)]
|
||||
pub audio_stream: usize,
|
||||
/// The media file, when the item is file media.
|
||||
pub path: Option<String>,
|
||||
/// Media time range to supply (handles included, clamped to the media).
|
||||
|
|
@ -120,7 +130,8 @@ pub struct NeedOptions {
|
|||
/// Extra media before and after every used range.
|
||||
pub handles: Tick,
|
||||
/// One range per clip ([`EssenceKey::Clip`]; for rendering clip effects) instead of one range
|
||||
/// per media item covering all its uses ([`EssenceKey::Media`]).
|
||||
/// per media item and container stream covering all its uses. Stream zero keeps
|
||||
/// the legacy [`EssenceKey::Media`] identity.
|
||||
pub per_clip: bool,
|
||||
/// Whole media instead of the used range (embedding / copying without trimming).
|
||||
pub whole_media: bool,
|
||||
|
|
@ -163,7 +174,7 @@ fn collect_needs(
|
|||
let (mut start, mut end) = ((lo - opts.handles).max(Tick::ZERO), hi + opts.handles);
|
||||
match project.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Media(m)) => {
|
||||
let Some(a) = m.info.audio() else { continue };
|
||||
let Some(a) = m.info.audio_streams.get(c.audio_stream) else { continue };
|
||||
let path = match &m.media {
|
||||
MediaRef::File { path } => Some(path.clone()),
|
||||
MediaRef::Generator(_) => None,
|
||||
|
|
@ -176,8 +187,17 @@ fn collect_needs(
|
|||
start = Tick::ZERO;
|
||||
end = dur.max(end);
|
||||
}
|
||||
let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::Media(item) };
|
||||
let e = by_item.entry(key).or_insert_with(|| AudioNeed { key, item, path, start, end, channels: a.channels, sample_rate: a.sample_rate });
|
||||
let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::media(item, c.audio_stream) };
|
||||
let e = by_item.entry(key).or_insert_with(|| AudioNeed {
|
||||
key,
|
||||
item,
|
||||
audio_stream: c.audio_stream,
|
||||
path,
|
||||
start,
|
||||
end,
|
||||
channels: a.channels,
|
||||
sample_rate: a.sample_rate,
|
||||
});
|
||||
e.start = e.start.min(start);
|
||||
e.end = e.end.max(end);
|
||||
}
|
||||
|
|
@ -196,7 +216,10 @@ fn collect_needs(
|
|||
_ => 2,
|
||||
};
|
||||
let key = EssenceKey::Clip(c.id);
|
||||
by_item.insert(key, AudioNeed { key, item, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) });
|
||||
by_item.insert(
|
||||
key,
|
||||
AudioNeed { key, item, audio_stream: 0, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) },
|
||||
);
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -65,14 +65,18 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
|
|||
`nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time,
|
||||
so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary
|
||||
CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed.
|
||||
No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12
|
||||
input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
|
||||
No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. 8-bit pictures go
|
||||
in as the export's RGBA (ABGR input buffers, a ring of eight) and NVENC converts them to BT.709
|
||||
limited 4:2:0 on the GPU, with the same codes as the software encoder's own conversion; a driver
|
||||
that refuses RGB input gets that CPU conversion into NV12 instead. The conversion used to run on
|
||||
the encode thread, where it competed with the render of the next frames for the thread pool and
|
||||
held NVENC back. The encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
|
||||
for Baseline), one B-frame when the profile and GPU allow it, and an IDR at every keyframe
|
||||
distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it.
|
||||
It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems
|
||||
without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export
|
||||
ends it with an error, since a hardware stream cannot be finished in software. The same session,
|
||||
ring and NV12 path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
|
||||
ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
|
||||
NVENC)](#hardware-h265-hevc-encoding-windows-nvenc).
|
||||
- **Linux: VA-API, H.264 (`avcC`) and HEVC (`hvcC`)**: H.264 8-bit 4:2:0 progressive, Constrained
|
||||
Baseline / Main / High; HEVC Main, Main 10 and Main Still Picture, 4:2:0 8- and 10-bit (`vaapi/`), on Intel (iHD, i965), AMD and other Mesa drivers. `libva.so.2` and `libva-drm.so.2`
|
||||
|
|
@ -281,6 +285,7 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main
|
|||
| `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters |
|
||||
| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames |
|
||||
| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC |
|
||||
| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors |
|
||||
| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) |
|
||||
| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe |
|
||||
| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight |
|
||||
|
|
|
|||
|
|
@ -129,7 +129,10 @@ impl HybridDecoder {
|
|||
}
|
||||
|
||||
/// Switch to the software decoder after `err`, replaying the log (which ends with the sample
|
||||
/// that failed).
|
||||
/// that failed). The replay goes on past samples the software decoder rejects, as decoding in
|
||||
/// software from the restart point would: one that the hardware had already decoded is logged
|
||||
/// (its call returned long ago), and if the failed sample itself is rejected its error is this
|
||||
/// call's, while the pictures replayed before it are kept for the next output.
|
||||
fn fall_back(&mut self, err: CodecError) -> Result<Vec<DecodedFrame>> {
|
||||
let Some(mut hw) = self.hw.take() else { return Err(err) };
|
||||
log::warn!("{} failed ({err}); continuing with the software decoder", hw.name());
|
||||
|
|
@ -149,12 +152,28 @@ impl HybridDecoder {
|
|||
return Err(CodecError::Decode(format!("{err} (continuing in software after the next seek)")));
|
||||
};
|
||||
let mut frames = Vec::new();
|
||||
for (s, p) in &log {
|
||||
let mut rejected = None;
|
||||
let last = log.len().saturating_sub(1);
|
||||
for (i, (s, p)) in log.iter().enumerate() {
|
||||
let Some(sw) = self.sw.as_mut() else { break };
|
||||
frames.extend(sw.decode(s, *p)?);
|
||||
match sw.decode(s, *p) {
|
||||
Ok(out) => frames.extend(out),
|
||||
Err(e) if i == last => rejected = Some(e),
|
||||
Err(e) => log::warn!("software replay after the hardware decoder failed: {e}"),
|
||||
}
|
||||
}
|
||||
self.log = Some(log);
|
||||
Ok(self.merge(frames))
|
||||
match rejected {
|
||||
None => Ok(self.merge(frames)),
|
||||
Some(e) => {
|
||||
for f in frames {
|
||||
if !self.emitted.contains(&f.pts) {
|
||||
self.carry.insert(f.pts, f);
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -444,6 +444,7 @@ fn constants_match_the_header() {
|
|||
assert_eq!(NV_ENC_LEVEL_HEVC_62 as i64, 186, "NV_ENC_LEVEL_HEVC_62");
|
||||
assert_eq!(NVENCAPI_VERSION as i64, 16777228, "NVENCAPI_VERSION");
|
||||
assert_eq!(NV_ENC_BUFFER_FORMAT_YUV420_10BIT as i64, 65536, "NV_ENC_BUFFER_FORMAT_YUV420_10BIT");
|
||||
assert_eq!(NV_ENC_BUFFER_FORMAT_ABGR as i64, 268435456, "NV_ENC_BUFFER_FORMAT_ABGR");
|
||||
assert_eq!(NV_ENC_CAPS_SUPPORT_10BIT_ENCODE as i64, 39, "NV_ENC_CAPS_SUPPORT_10BIT_ENCODE");
|
||||
assert_eq!(NV_ENC_VUI_COLOR_PRIMARIES_BT2020 as i64, 9, "NV_ENC_VUI_COLOR_PRIMARIES_BT2020");
|
||||
assert_eq!(NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084 as i64, 16, "NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084");
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ impl VideoEncoder for NvencEncoder {
|
|||
}
|
||||
(true, None) => return Err(ExportError::Unsupported("NVENC Main 10 needs the HDR picture (EncoderFrame::hdr)".into())),
|
||||
(false, Some(_)) => return Err(ExportError::Unsupported("NVENC Main (8-bit) does not take HDR pictures".into())),
|
||||
// the GPU converts the RGBA picture itself (BT.709 limited range, the same codes as `rgba_to_yuv420_8`)
|
||||
(false, None) if self.enc.takes_rgba() => self.enc.encode_rgba(f.rgba, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))?,
|
||||
(false, None) => {
|
||||
filmcraft_export::timed(filmcraft_export::Stage::Convert, || {
|
||||
filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v)
|
||||
|
|
@ -209,7 +211,21 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
|
|||
// H.265 through NVENC is Windows-only for now; on Linux this backend takes H.264 only
|
||||
return if cfg!(target_os = "windows") { hevc_factory(w, h, rate, s) } else { None };
|
||||
}
|
||||
factory_with(format, w, h, rate, s, Nvenc::new)
|
||||
factory_with(format, w, h, rate, s, |cfg| open(cfg, &Signal::default()))
|
||||
}
|
||||
|
||||
/// Open the encoder for `cfg`. An 8-bit one takes the export's RGBA pictures as they are and converts
|
||||
/// them on the GPU, which keeps the RGB → 4:2:0 conversion off the CPU (where it competes with the
|
||||
/// render of the next frames); where the driver refuses RGB input, it takes the CPU's 4:2:0 as before.
|
||||
/// Main 10 takes its 10-bit planes.
|
||||
fn open(cfg: &Config, signal: &Signal) -> std::result::Result<Nvenc, String> {
|
||||
if cfg.profile == Profile::HevcMain10 {
|
||||
return Nvenc::with_signal(cfg, signal);
|
||||
}
|
||||
Nvenc::with_rgba_input(cfg, signal).or_else(|why| {
|
||||
log::info!("NVENC RGBA input declined ({why}): converting to 4:2:0 on the CPU");
|
||||
Nvenc::with_signal(cfg, signal)
|
||||
})
|
||||
}
|
||||
|
||||
/// The H.264 side of [`factory`], with the driver call (`open`) passed in so tests can stand in for it.
|
||||
|
|
@ -254,7 +270,7 @@ fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<R
|
|||
Ok(c) => c,
|
||||
Err(why) => return declined(&why),
|
||||
};
|
||||
match Nvenc::with_signal(&cfg, &nvenc_signal(&s.signal)) {
|
||||
match open(&cfg, &nvenc_signal(&s.signal)) {
|
||||
Ok(enc) => {
|
||||
filmcraft_export::note_hw_encode_session();
|
||||
Some(Ok(Box::new(new_encoder(enc, w, h, rate, s.signal))))
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ pub const NV_ENC_MEMORY_HEAP_SYSMEM_CACHED: u32 = 2;
|
|||
pub const NV_ENC_BUFFER_FORMAT_NV12: u32 = 0x1;
|
||||
/// P010: 10-bit 4:2:0, 16-bit little-endian samples with the value in the high 10 bits.
|
||||
pub const NV_ENC_BUFFER_FORMAT_YUV420_10BIT: u32 = 0x10000;
|
||||
/// ABGR: 8-bit packed, one 32-bit little-endian word per pixel with R in the lowest byte (bytes R, G, B, A).
|
||||
pub const NV_ENC_BUFFER_FORMAT_ABGR: u32 = 0x10000000;
|
||||
pub const NV_ENC_PARAMS_RC_VBR: u32 = 1;
|
||||
pub const NV_ENC_PARAMS_RC_CBR: u32 = 2;
|
||||
pub const NV_ENC_TUNING_INFO_HIGH_QUALITY: u32 = 1;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@
|
|||
//! only encoder of the H.265 format (which has no software encoder).
|
||||
//!
|
||||
//! ```text
|
||||
//! RGBA (the export pipeline) ──► BT.709 limited 4:2:0 (the software encoder's conversion)
|
||||
//! ──► NV12 input buffer ──NVENC──► Annex B ──► length-prefixed samples + avcC / hvcC
|
||||
//! RGBA (the export pipeline) ──► ABGR input buffer ──NVENC (BT.709 limited 4:2:0 on the GPU)──►
|
||||
//! Annex B ──► length-prefixed samples + avcC / hvcC
|
||||
//!
|
||||
//! where the driver refuses RGB input (`Nvenc::with_rgba_input` fails), as before:
|
||||
//! RGBA ──► BT.709 limited 4:2:0 (the software encoder's conversion) ──► NV12 input buffer ──NVENC──► …
|
||||
//!
|
||||
//! HDR (PQ / HLG) HEVC Main 10:
|
||||
//! encoded BT.2020 R'G'B' floats ──► 10-bit limited 4:2:0 (`filmcraft_export::rgbf_to_yuv420_10`)
|
||||
|
|
@ -120,6 +123,8 @@ pub struct Nvenc {
|
|||
size: (u32, u32),
|
||||
/// Main 10: pictures go in as 10-bit P010 ([`Nvenc::encode_10`]), otherwise as 8-bit NV12 ([`Nvenc::encode`]).
|
||||
ten_bit: bool,
|
||||
/// Pictures go in as packed RGBA ([`Nvenc::encode_rgba`]) and the GPU converts them to 4:2:0.
|
||||
rgba_input: bool,
|
||||
}
|
||||
|
||||
/// The H.264 encoder (the name it had before HEVC joined it).
|
||||
|
|
@ -231,6 +236,19 @@ impl Nvenc {
|
|||
/// on every IDR picture), or say why NVENC does not take this configuration. H.264 takes the
|
||||
/// default signal only.
|
||||
pub fn with_signal(cfg: &Config, signal: &Signal) -> Result<Self, String> {
|
||||
Self::open(cfg, signal, false)
|
||||
}
|
||||
|
||||
/// Open an 8-bit encoder that takes straight RGBA8 pictures ([`Nvenc::encode_rgba`]) and converts
|
||||
/// them to 4:2:0 on the GPU, or say why not (Main 10, or a driver that refuses RGB input).
|
||||
pub fn with_rgba_input(cfg: &Config, signal: &Signal) -> Result<Self, String> {
|
||||
if cfg.profile == Profile::HevcMain10 {
|
||||
return Err("RGBA input is 8-bit only".into());
|
||||
}
|
||||
Self::open(cfg, signal, true)
|
||||
}
|
||||
|
||||
fn open(cfg: &Config, signal: &Signal, rgba_input: bool) -> Result<Self, String> {
|
||||
let (w, h) = (cfg.width, cfg.height);
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
return Err(format!("{w}x{h}: NVENC needs even dimensions"));
|
||||
|
|
@ -278,6 +296,7 @@ impl Nvenc {
|
|||
Profile::High => 2,
|
||||
},
|
||||
ten_bit,
|
||||
rgba_input,
|
||||
signal: signal.clone(),
|
||||
level,
|
||||
sar: cfg.sar,
|
||||
|
|
@ -312,6 +331,7 @@ impl Nvenc {
|
|||
emitted: 0,
|
||||
size: (w, h),
|
||||
ten_bit,
|
||||
rgba_input,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -345,6 +365,25 @@ impl Nvenc {
|
|||
self.ten_bit
|
||||
}
|
||||
|
||||
/// Whether this encoder takes packed RGBA pictures ([`Nvenc::encode_rgba`]) instead of planar ones.
|
||||
pub fn takes_rgba(&self) -> bool {
|
||||
self.rgba_input
|
||||
}
|
||||
|
||||
/// Encode picture `index` from straight RGBA8 (`w * 4` bytes per row, alpha ignored); NVENC
|
||||
/// converts it to 4:2:0 with the stream's matrix. The pictures that came out, as for
|
||||
/// [`Nvenc::encode`]. An error for an encoder opened without [`Nvenc::with_rgba_input`].
|
||||
pub fn encode_rgba(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>, String> {
|
||||
if !self.rgba_input {
|
||||
return Err("an RGBA picture for a planar-input encoder".into());
|
||||
}
|
||||
let (w, h) = (self.size.0 as usize, self.size.1 as usize);
|
||||
if rgba.len() < w.saturating_mul(h).saturating_mul(4) {
|
||||
return Err("the picture is smaller than the encoder's size".into());
|
||||
}
|
||||
self.submit_picture(index, |l| fill_rgba(l, rgba, w, h))
|
||||
}
|
||||
|
||||
/// Encode picture `index` from planar 8-bit 4:2:0 (`u`, `v` at half size, rows `w` and `w / 2` bytes).
|
||||
/// Returns the pictures that came out (usually the oldest; none while the ring fills). An error
|
||||
/// for a Main 10 encoder.
|
||||
|
|
@ -352,6 +391,9 @@ impl Nvenc {
|
|||
if self.ten_bit {
|
||||
return Err("an 8-bit picture for a Main 10 encoder".into());
|
||||
}
|
||||
if self.rgba_input {
|
||||
return Err("a planar picture for an RGBA-input encoder".into());
|
||||
}
|
||||
let (w, h) = (self.size.0 as usize, self.size.1 as usize);
|
||||
let (luma, chroma) = (w.saturating_mul(h), (w / 2).saturating_mul(h / 2));
|
||||
if y.len() < luma || u.len() < chroma || v.len() < chroma {
|
||||
|
|
@ -455,6 +497,20 @@ fn fill_nv12(l: Locked<'_>, y: &[u8], u: &[u8], v: &[u8], w: usize, h: usize) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Copy straight RGBA8 rows into an ABGR input buffer (the same byte order: R, G, B, A). A row the
|
||||
/// pitch cannot hold is skipped, never overrun.
|
||||
fn fill_rgba(l: Locked<'_>, rgba: &[u8], w: usize, h: usize) {
|
||||
let row = w.saturating_mul(4);
|
||||
if row == 0 || l.pitch == 0 {
|
||||
return;
|
||||
}
|
||||
for (src, dst) in rgba.chunks_exact(row).zip(l.data.chunks_mut(l.pitch)).take(h) {
|
||||
if let Some(d) = dst.get_mut(..row) {
|
||||
d.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy planar 10-bit 4:2:0 into a P010 input buffer: each sample is a little-endian `u16` with the
|
||||
/// 10-bit code in its high bits (`code << 6`), the chroma plane interleaved (Cb, Cr) after the luma rows,
|
||||
/// `pitch` in bytes. Codes above 10 bits are masked; a row the pitch cannot hold is skipped, never overrun.
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ pub struct Params {
|
|||
pub profile: u8,
|
||||
/// HEVC Main 10: 10-bit 4:2:0 pictures (P010), otherwise 8-bit (NV12).
|
||||
pub ten_bit: bool,
|
||||
/// 8-bit only: pictures go in as packed RGBA (ABGR) and NVENC converts them to 4:2:0 on the GPU.
|
||||
pub rgba_input: bool,
|
||||
/// HEVC: the VUI colour description and the SEI messages of every IDR picture (H.264 keeps BT.709).
|
||||
pub signal: Signal,
|
||||
/// The codec's own level code: `level × 10` for H.264, `level × 30` for HEVC.
|
||||
|
|
@ -160,7 +162,7 @@ pub struct Params {
|
|||
}
|
||||
|
||||
/// An input buffer locked for writing: NV12 (or P010) rows go to `data` at `pitch` bytes per row, the
|
||||
/// chroma plane after `pitch * height` bytes.
|
||||
/// chroma plane after `pitch * height` bytes; packed RGBA (ABGR) has `height` rows and no chroma plane.
|
||||
pub struct Locked<'a> {
|
||||
pub data: &'a mut [u8],
|
||||
pub pitch: usize,
|
||||
|
|
@ -315,8 +317,14 @@ impl Session {
|
|||
if !p.signal.sei.is_empty() && p.codec != Codec::Hevc {
|
||||
return Err("SEI messages are written for HEVC only".into());
|
||||
}
|
||||
self.fmt = if p.ten_bit { NV_ENC_BUFFER_FORMAT_YUV420_10BIT } else { NV_ENC_BUFFER_FORMAT_NV12 };
|
||||
self.bytes_per_sample = if p.ten_bit { 2 } else { 1 };
|
||||
if p.ten_bit && p.rgba_input {
|
||||
return Err("RGBA input is 8-bit only".into());
|
||||
}
|
||||
(self.fmt, self.bytes_per_sample) = match (p.ten_bit, p.rgba_input) {
|
||||
(true, _) => (NV_ENC_BUFFER_FORMAT_YUV420_10BIT, 2),
|
||||
(false, true) => (NV_ENC_BUFFER_FORMAT_ABGR, 4),
|
||||
(false, false) => (NV_ENC_BUFFER_FORMAT_NV12, 1),
|
||||
};
|
||||
self.gop = u64::from(p.gop.max(1));
|
||||
self.submitted = 0;
|
||||
// the SEI payloads: heap blocks that outlive every picture (see the field documentation)
|
||||
|
|
@ -516,7 +524,8 @@ impl Session {
|
|||
let (w, h) = self.size;
|
||||
// SAFETY: the buffer handle is one this session created. `LockInputBuffer` returns a
|
||||
// writable block of `pitch * h * 3 / 2` bytes (NV12 or P010: luma rows then chroma rows, the pitch
|
||||
// in bytes) that stays valid until `UnlockInputBuffer`; the slice borrows it only inside `fill`.
|
||||
// in bytes), or `pitch * h` bytes (ABGR: packed rows only), that stays valid until
|
||||
// `UnlockInputBuffer`; the slice borrows it only inside `fill`.
|
||||
let pitch = unsafe {
|
||||
let mut l: NV_ENC_LOCK_INPUT_BUFFER = std::mem::zeroed();
|
||||
l.version = NV_ENC_LOCK_INPUT_BUFFER_VER;
|
||||
|
|
@ -531,7 +540,8 @@ impl Session {
|
|||
let _ = unlock(self.enc, input);
|
||||
return Err(format!("the driver returned an input pitch of {} bytes for {w}-pixel rows of {}-byte samples", l.pitch, self.bytes_per_sample));
|
||||
}
|
||||
let len = (l.pitch as usize).saturating_mul(h as usize).saturating_mul(3) / 2;
|
||||
let rows = (l.pitch as usize).saturating_mul(h as usize);
|
||||
let len = if self.fmt == NV_ENC_BUFFER_FORMAT_ABGR { rows } else { rows.saturating_mul(3) / 2 };
|
||||
fill(Locked { data: std::slice::from_raw_parts_mut(l.bufferDataPtr.cast::<u8>(), len), pitch: l.pitch as usize });
|
||||
let st = unlock(self.enc, input);
|
||||
if st != NV_ENC_SUCCESS {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
use std::ffi::{CStr, c_char, c_int, c_uint, c_void};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
|
||||
use filmcraft_frame::VideoFrame;
|
||||
use libloading::Library;
|
||||
|
|
@ -125,9 +126,49 @@ pub struct Display {
|
|||
_device: File,
|
||||
}
|
||||
|
||||
// SAFETY: a VA display is not tied to the thread that opened it, and a `Display` is only used
|
||||
// through its single owner (`&mut` or `&` of one `Session` at a time), never concurrently.
|
||||
// SAFETY: a VA display is not tied to the thread that opened it.
|
||||
unsafe impl Send for Display {}
|
||||
// SAFETY: libva's functions are thread-safe and so must its drivers' be (va.h, "Multithreading
|
||||
// Guide"): one display may be used from several threads at once, as long as each VA object
|
||||
// (context, surfaces, buffers, images) is used by one thread at a time. Every `Session` on a
|
||||
// shared display has its own configuration, context, surfaces and image, used only through that
|
||||
// session (`&mut` or `&` of one owner); the display itself is only read (`api`, `dpy`, `vendor`).
|
||||
unsafe impl Sync for Display {}
|
||||
|
||||
/// The display every [`Session`] decodes on. Opening a display takes a few milliseconds with
|
||||
/// Intel's and Mesa's drivers but 0.2-0.3 s with NVIDIA's (a CUDA context each, about 100 MB of
|
||||
/// video memory), and terminating one 60-70 ms, on the thread that creates or drops the decoder: a
|
||||
/// frame worker at every clip on the timeline, the UI thread when media is removed. So one display
|
||||
/// is opened (by [`probe`] at startup) and kept for the life of the process, like the libraries,
|
||||
/// and sessions only make and destroy their own configuration, context and surfaces.
|
||||
static SHARED: Mutex<Option<Arc<Display>>> = Mutex::new(None);
|
||||
/// Displays opened so far (diagnostics and tests).
|
||||
static OPENED: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// The number of VA displays this process has opened (one, unless the shared one stopped working).
|
||||
pub fn displays_opened() -> usize {
|
||||
OPENED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The shared display, opened on first use.
|
||||
fn shared() -> Result<Arc<Display>, String> {
|
||||
let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(d) = shared.as_ref() {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let d = Arc::new(Display::open()?);
|
||||
*shared = Some(d.clone());
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Share `new` from now on instead of `old` (if `old` is still the shared one). Sessions on `old`
|
||||
/// keep it until they end.
|
||||
fn replace_shared(old: &Arc<Display>, new: Arc<Display>) {
|
||||
let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if shared.as_ref().is_none_or(|d| Arc::ptr_eq(d, old)) {
|
||||
*shared = Some(new);
|
||||
}
|
||||
}
|
||||
|
||||
impl Display {
|
||||
/// The first DRM render node with a working VA-API driver.
|
||||
|
|
@ -164,6 +205,7 @@ impl Display {
|
|||
// SAFETY: `dpy` is valid; the version outputs point to live locals.
|
||||
let st = unsafe { (api.initialize)(dpy, &mut major, &mut minor) };
|
||||
d.check(st, "vaInitialize")?;
|
||||
OPENED.fetch_add(1, Ordering::Relaxed);
|
||||
// SAFETY: `dpy` is an initialised display; the vendor string is NUL-terminated and owned by
|
||||
// the driver for the life of the display (copied out here).
|
||||
let vendor = unsafe { (api.vendor_string)(dpy) };
|
||||
|
|
@ -206,7 +248,8 @@ impl Drop for Display {
|
|||
/// One stream's decoder: a VLD configuration of a profile, its surfaces and context, and the image
|
||||
/// pictures are read back through.
|
||||
pub struct Session {
|
||||
display: Display,
|
||||
/// The shared display (see [`SHARED`]), or the one that replaced it.
|
||||
display: Arc<Display>,
|
||||
config: ffi::VAConfigID,
|
||||
context: ffi::VAContextID,
|
||||
surfaces: Vec<VASurfaceID>,
|
||||
|
|
@ -231,8 +274,23 @@ impl Session {
|
|||
/// 8 or 10), with `count` surfaces of `size` (coded luma samples); pictures read back are cut
|
||||
/// to `geometry`.
|
||||
pub fn new(profiles: &[ffi::VAProfile], size: (u32, u32), count: usize, geometry: Geometry) -> Result<Self, String> {
|
||||
let display = shared()?;
|
||||
match Self::on(display.clone(), profiles, size, count, geometry) {
|
||||
Ok(s) => Ok(s),
|
||||
Err(first) => {
|
||||
// The shared display may have stopped working (a GPU reset): try a new one, and
|
||||
// share that one from now on if it works. (A stream the driver does not take
|
||||
// fails on both and costs the one extra display opening.)
|
||||
let fresh = Arc::new(Display::open().map_err(|e| format!("{first}; a new display: {e}"))?);
|
||||
let s = Self::on(fresh.clone(), profiles, size, count, geometry).map_err(|_| first)?;
|
||||
replace_shared(&display, fresh);
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on(display: Arc<Display>, profiles: &[ffi::VAProfile], size: (u32, u32), count: usize, geometry: Geometry) -> Result<Self, String> {
|
||||
let (rt_format, _, _) = formats(geometry.bits)?;
|
||||
let display = Display::open()?;
|
||||
let api = display.api;
|
||||
let dpy = display.dpy;
|
||||
let mut config = None;
|
||||
|
|
@ -497,8 +555,9 @@ impl Accel for Session {
|
|||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
let (api, dpy) = (self.display.api, self.display.dpy);
|
||||
// SAFETY: each object below was made on this display and is destroyed once; the display
|
||||
// itself is terminated afterwards (when the `display` field is dropped).
|
||||
// SAFETY: each object below was made on this display and is destroyed once, by this
|
||||
// session only; the display itself is terminated when its last user lets it go (the
|
||||
// shared one never is).
|
||||
unsafe {
|
||||
if let Some(image) = self.image.take() {
|
||||
(api.destroy_image)(dpy, image.image_id);
|
||||
|
|
@ -521,5 +580,6 @@ impl Drop for Session {
|
|||
/// driver's description, or why not.
|
||||
pub fn probe() -> &'static Result<String, String> {
|
||||
static PROBE: OnceLock<Result<String, String>> = OnceLock::new();
|
||||
PROBE.get_or_init(|| Display::open().map(|d| d.vendor().to_string()))
|
||||
// the display opened to answer is the one sessions share
|
||||
PROBE.get_or_init(|| shared().map(|d| d.vendor().to_string()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
//! [`HybridDecoder`] fallback on every platform, with a stand-in "hardware" decoder (our software
|
||||
//! decoder that starts failing after N samples, as a lost hardware session does): the output must
|
||||
//! be exactly the software decoder's, whatever sample the failure hits. Also: in-band parameter
|
||||
//! sets that differ from the sample entry's switch to software; identical ones do not.
|
||||
//! sets that differ from the sample entry's switch to software; identical ones do not; and a
|
||||
//! damaged sample that the hardware decoded but the software decoder rejects, inside the replayed
|
||||
//! run, loses no picture.
|
||||
|
||||
mod common;
|
||||
|
||||
|
|
@ -105,3 +107,107 @@ fn changed_in_band_parameter_sets_switch_to_software() {
|
|||
assert!(!d.is_hardware(), "changed SPS: software");
|
||||
assert_same("changed SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &changed));
|
||||
}
|
||||
|
||||
/// Like [`Failing`], but tolerant of one damaged sample the way a GPU can be: at `damaged_at` it
|
||||
/// decodes the intact sample instead of the one it is given (which the software decoder rejects).
|
||||
struct Tolerant {
|
||||
inner: Box<dyn VideoDecoder>,
|
||||
intact: Vec<u8>,
|
||||
damaged_at: usize,
|
||||
fail_after: usize,
|
||||
fed: usize,
|
||||
}
|
||||
|
||||
impl VideoDecoder for Tolerant {
|
||||
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
|
||||
let i = self.fed;
|
||||
self.fed += 1;
|
||||
if self.fed > self.fail_after {
|
||||
return Err(filmcraft_codecs::CodecError::Decode("session invalidated (test)".into()));
|
||||
}
|
||||
self.inner.decode(if i == self.damaged_at { &self.intact } else { sample }, pts)
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
self.inner.flush()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"stand-in hardware"
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed `samples` then flush, carrying on past samples the decoder rejects (as playback does);
|
||||
/// returns the pictures and the indices of the rejected samples.
|
||||
fn decode_lenient(d: &mut dyn VideoDecoder, samples: &[(Vec<u8>, i64)]) -> (Vec<DecodedFrame>, Vec<usize>) {
|
||||
let (mut out, mut rejected) = (Vec::new(), Vec::new());
|
||||
for (i, (s, p)) in samples.iter().enumerate() {
|
||||
match d.decode(s, *p) {
|
||||
Ok(f) => out.extend(f),
|
||||
Err(_) => rejected.push(i),
|
||||
}
|
||||
}
|
||||
out.extend(d.flush());
|
||||
(out, rejected)
|
||||
}
|
||||
|
||||
/// The sample's NAL unit with its header kept and its payload overwritten: the slice header no
|
||||
/// longer parses, so the software decoder rejects the sample.
|
||||
fn damaged(sample: &[u8], nal_header: usize) -> Vec<u8> {
|
||||
let mut v = sample.to_vec();
|
||||
let start = (4 + nal_header).min(v.len());
|
||||
v[start..].fill(0xff);
|
||||
v
|
||||
}
|
||||
|
||||
/// A replay (after the hardware fails) that meets a sample the software decoder rejects must go
|
||||
/// on to the end of the replayed run, like decoding in software does, and must not lose the
|
||||
/// pictures it decoded before that sample: every picture the software decoder outputs for the
|
||||
/// stream is output, once and in order, and from the next restart point on the pictures are the
|
||||
/// software decoder's.
|
||||
#[test]
|
||||
fn a_sample_software_rejects_during_the_replay_loses_no_pictures() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let mut checked = 0;
|
||||
for (name, _) in FIXTURES {
|
||||
let Some(path) = named(&ff, name) else { continue };
|
||||
let s = read_stream(&path);
|
||||
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
|
||||
let header = if name.starts_with("hevc") { 2 } else { 1 };
|
||||
// the second restart point, and a disposable picture before it that is not the first sample
|
||||
let Some(next) = (1..s.samples.len()).find(|&i| s.sync[i]) else { continue };
|
||||
let Some(d) = (2..next.saturating_sub(2)).find(|&i| info.is_disposable(&s.samples[i].0)) else { continue };
|
||||
let mut stream = s.samples.clone();
|
||||
stream[d].0 = damaged(&s.samples[d].0, header);
|
||||
let (reference, rejected) = decode_lenient(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &stream);
|
||||
assert_eq!(rejected, [d], "{name}: the software decoder rejects the damaged sample (and only it)");
|
||||
let restart = stream[next..].iter().map(|x| x.1).min().unwrap();
|
||||
// failing at the damaged sample itself (the software decoder rejects the failed sample
|
||||
// too), and after the hardware had decoded it
|
||||
for fail_after in [d, d + 1, d + 2, next - 1] {
|
||||
let tolerant = Tolerant {
|
||||
inner: filmcraft_codecs::software_video_decoder(&s.entry).unwrap(),
|
||||
intact: s.samples[d].0.clone(),
|
||||
damaged_at: d,
|
||||
fail_after,
|
||||
fed: 0,
|
||||
};
|
||||
let mut h = HybridDecoder::new(Box::new(tolerant), s.entry.clone(), info.clone());
|
||||
let (out, errors) = decode_lenient(&mut h, &stream);
|
||||
let what = format!("{name}: sample {d} damaged, hardware failing after {fail_after}");
|
||||
assert!(!h.is_hardware(), "{what}: fell back");
|
||||
// the damaged sample is reported as the software decoder reports it, unless the
|
||||
// hardware had already decoded it
|
||||
assert_eq!(errors, if fail_after == d { vec![d] } else { vec![] }, "{what}: rejected samples");
|
||||
let pts: Vec<i64> = out.iter().map(|f| f.pts).collect();
|
||||
assert!(pts.windows(2).all(|w| w[0] < w[1]), "{what}: pictures once and in order: {pts:?}");
|
||||
let lost: Vec<i64> = reference.iter().map(|f| f.pts).filter(|p| !pts.contains(p)).collect();
|
||||
assert!(lost.is_empty(), "{what}: lost pictures at pts {lost:?}");
|
||||
let from = |v: &[DecodedFrame]| v.iter().position(|f| f.pts >= restart).unwrap_or(v.len());
|
||||
assert_same(&format!("{what}, from the next restart point"), &out[from(&out)..], &reference[from(&reference)..]);
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, FIXTURES.len(), "every fixture has a damaged-sample case");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
//! NVENC with packed RGBA input (Windows, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the
|
||||
//! export's own BT.709 limited-range conversion (`filmcraft_export::rgba_to_yuv420_8`), for HEVC Main
|
||||
//! and H.264. Skips without an NVIDIA GPU / driver.
|
||||
#![cfg(target_os = "windows")]
|
||||
|
||||
use filmcraft_isobmff::{AvcConfig, SampleEntry};
|
||||
use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available};
|
||||
|
||||
/// NVENC sessions are a limited resource on consumer GPUs: one test at a time.
|
||||
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// A moving test picture: gradient background, a moving box, a little texture.
|
||||
fn moving(w: usize, h: usize, i: usize) -> Vec<u8> {
|
||||
let mut v = vec![0u8; w * h * 4];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let o = (y * w + x) * 4;
|
||||
let (bx, by) = ((i * 7) % (w - 64), (i * 3) % (h - 64));
|
||||
let in_box = x >= bx && x < bx + 64 && y >= by && y < by + 64;
|
||||
let tex = ((x * 31 + y * 17 + i * 5) % 23) as u8;
|
||||
v[o] = if in_box { 230 } else { (x * 200 / w) as u8 + tex };
|
||||
v[o + 1] = if in_box { 40 } else { (y * 200 / h) as u8 + tex };
|
||||
v[o + 2] = if in_box { 60 } else { 90 + tex };
|
||||
v[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn solid(w: usize, h: usize, rgb: [u8; 3]) -> Vec<u8> {
|
||||
[rgb[0], rgb[1], rgb[2], 255].repeat(w * h)
|
||||
}
|
||||
|
||||
fn config(w: u32, h: u32, profile: Profile) -> Config {
|
||||
Config {
|
||||
width: w,
|
||||
height: h,
|
||||
fps: (30, 1),
|
||||
bitrate_kbps: 8000,
|
||||
max_bitrate_kbps: 12000,
|
||||
cbr: false,
|
||||
keyint: 30,
|
||||
profile,
|
||||
level: None,
|
||||
sar: None,
|
||||
bframes: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn psnr(a: &[u8], b: &[u8]) -> f64 {
|
||||
let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::<f64>() / a.len() as f64;
|
||||
if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() }
|
||||
}
|
||||
|
||||
fn mean(p: &[u8]) -> f64 {
|
||||
p.iter().map(|v| f64::from(*v)).sum::<f64>() / p.len() as f64
|
||||
}
|
||||
|
||||
/// Encode `frames` through the RGBA input, decode with our own decoder: the decoded planes by picture.
|
||||
fn round_trip(profile: Profile, w: usize, h: usize, frames: &[Vec<u8>]) -> Vec<[Vec<u8>; 3]> {
|
||||
let mut enc =
|
||||
Nvenc::with_rgba_input(&config(w as u32, h as u32, profile), &Signal::default()).unwrap_or_else(|e| panic!("{profile:?} with RGBA input: {e}"));
|
||||
assert!(enc.takes_rgba());
|
||||
assert!(enc.encode(&[0; 4], &[0; 1], &[0; 1], 0).is_err(), "planar pictures are refused");
|
||||
let mut packets: Vec<Packet> = Vec::new();
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
packets.extend(enc.encode_rgba(f, i as u64).unwrap());
|
||||
}
|
||||
packets.extend(enc.flush().unwrap());
|
||||
assert_eq!(packets.len(), frames.len(), "one packet per picture");
|
||||
let entry = match enc.codec() {
|
||||
Codec::Hevc => SampleEntry::hevc(enc.hevc_config().expect("an hvcC record").clone(), w as u16, h as u16),
|
||||
Codec::H264 => {
|
||||
let (sps, pps) = enc.parameter_sets();
|
||||
SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w as u16, h as u16)
|
||||
}
|
||||
};
|
||||
let mut dec = filmcraft_codecs::software_video_decoder(&entry).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for p in &packets {
|
||||
out.extend(dec.decode(&p.data, p.pts).unwrap());
|
||||
}
|
||||
out.extend(dec.flush());
|
||||
out.sort_by_key(|f| f.pts);
|
||||
assert_eq!(out.len(), frames.len());
|
||||
out.into_iter()
|
||||
.map(|f| {
|
||||
let filmcraft_frame::PixelData::Yuv8 { planes, .. } = &f.frame.data else { panic!("8-bit planes") };
|
||||
[planes[0].to_vec(), planes[1].to_vec(), planes[2].to_vec()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn check(profile: Profile) {
|
||||
let (w, h) = (1280usize, 720usize);
|
||||
// solid colours: the decoded means are the BT.709 limited-range codes of the export's own conversion
|
||||
let colours: [[u8; 3]; 6] = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255], [0, 0, 0], [128, 128, 128]];
|
||||
let mut frames: Vec<Vec<u8>> = colours.iter().map(|c| solid(w, h, *c)).collect();
|
||||
frames.extend((0..24).map(|i| moving(w, h, i)));
|
||||
let decoded = round_trip(profile, w, h, &frames);
|
||||
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (k, c) in colours.iter().enumerate() {
|
||||
filmcraft_export::rgba_to_yuv420_8(&frames[k], w, h, &mut y, &mut u, &mut v);
|
||||
let want = [f64::from(y[0]), f64::from(u[0]), f64::from(v[0])];
|
||||
let got = [mean(&decoded[k][0]), mean(&decoded[k][1]), mean(&decoded[k][2])];
|
||||
eprintln!("{profile:?} {c:?}: Y/Cb/Cr {got:.1?}, BT.709 limited {want:?}");
|
||||
for (g, e) in got.iter().zip(want) {
|
||||
assert!((g - e).abs() <= 2.0, "{profile:?} {c:?}: decoded {got:.1?}, expected {want:?}");
|
||||
}
|
||||
}
|
||||
// a moving picture: close to the export's own conversion encoded the planar way
|
||||
let (mut worst, mut worst_chroma) = (99.0f64, 99.0f64);
|
||||
for (k, f) in frames.iter().enumerate().skip(colours.len()) {
|
||||
filmcraft_export::rgba_to_yuv420_8(f, w, h, &mut y, &mut u, &mut v);
|
||||
worst = worst.min(psnr(&decoded[k][0], &y));
|
||||
worst_chroma = worst_chroma.min(psnr(&decoded[k][1], &u)).min(psnr(&decoded[k][2], &v));
|
||||
}
|
||||
eprintln!("{profile:?}: worst luma PSNR {worst:.1} dB, chroma {worst_chroma:.1} dB");
|
||||
assert!(worst > 40.0, "{profile:?} luma PSNR {worst:.1} dB");
|
||||
assert!(worst_chroma > 40.0, "{profile:?} chroma PSNR {worst_chroma:.1} dB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_rgba_input_converts_like_the_export() {
|
||||
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !hevc_available() {
|
||||
eprintln!("SKIPPED: no NVENC HEVC encoder");
|
||||
return;
|
||||
}
|
||||
check(Profile::HevcMain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_rgba_input_converts_like_the_export() {
|
||||
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !available() {
|
||||
eprintln!("SKIPPED: no NVENC");
|
||||
return;
|
||||
}
|
||||
check(Profile::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main10_has_no_rgba_input_and_short_pictures_are_errors() {
|
||||
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !hevc_available() {
|
||||
eprintln!("SKIPPED: no NVENC HEVC encoder");
|
||||
return;
|
||||
}
|
||||
assert!(Nvenc::with_rgba_input(&config(640, 360, Profile::HevcMain10), &Signal::default()).is_err());
|
||||
let mut enc = Nvenc::with_rgba_input(&config(640, 360, Profile::HevcMain), &Signal::default()).unwrap();
|
||||
assert!(enc.encode_rgba(&[], 0).is_err());
|
||||
assert!(enc.encode_rgba(&vec![0; 640 * 360 * 4 - 1], 0).is_err());
|
||||
let mut planar = Nvenc::new(&config(640, 360, Profile::HevcMain)).unwrap();
|
||||
assert!(!planar.takes_rgba());
|
||||
assert!(planar.encode_rgba(&solid(640, 360, [1, 2, 3]), 0).is_err(), "RGBA pictures for a planar encoder");
|
||||
}
|
||||
|
|
@ -36,8 +36,14 @@ fn off_uses_the_software_decoder() {
|
|||
assert_eq!(d.name(), "FilmCraft HEVC");
|
||||
return;
|
||||
}
|
||||
let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") };
|
||||
assert_eq!(d.name(), hw_name);
|
||||
let backend = if cfg!(target_os = "linux") {
|
||||
assert!(d.name().starts_with("VA-API HEVC ("), "Linux Auto decoder: {}", d.name());
|
||||
"VA-API"
|
||||
} else {
|
||||
let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") };
|
||||
assert_eq!(d.name(), hw_name);
|
||||
backend
|
||||
};
|
||||
assert_eq!(filmcraft_codecs::hw::hw_backend(), Some(backend), "perf.stats reports the backend");
|
||||
assert!(filmcraft_platform::registered(), "register() put the factory in the registry");
|
||||
let h1 = filmcraft_codecs::hw::hw_stats();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
//! VA-API decoders share one display (Linux). Opening a display takes 0.2-0.3 s with NVIDIA's
|
||||
//! driver (about 100 MB of video memory each) and terminating one 60-70 ms, on the thread that
|
||||
//! creates or drops the decoder (a frame worker at every clip, the UI thread when media is
|
||||
//! removed), so the process opens one and every decoder makes only its own context and surfaces on
|
||||
//! it, also several decoders at once on different threads. Its own test binary: the count of
|
||||
//! opened displays is process-wide, and other tests running at the same time would change it.
|
||||
//! Skips without ffmpeg (fixture generator) or without a VA-API driver.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use filmcraft_codecs::VideoDecoder;
|
||||
use filmcraft_platform::vaapi::va::displays_opened;
|
||||
|
||||
fn hardware(s: &Stream) -> Option<Box<dyn VideoDecoder>> {
|
||||
match filmcraft_platform::vaapi_factory(&s.entry) {
|
||||
Some(Ok(d)) if d.name().starts_with("VA-API") => Some(d),
|
||||
_ => {
|
||||
eprintln!("SKIPPED: no VA-API decoder for this stream on this machine");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoders_share_one_display() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let paths: Vec<_> = FIXTURES.iter().filter_map(|(name, _)| named(&ff, name)).collect();
|
||||
let streams: Vec<_> = paths.iter().map(|p| read_stream(p)).collect();
|
||||
let Some(first) = streams.first() else { return };
|
||||
let Some(hw) = hardware(first) else { return };
|
||||
drop(hw);
|
||||
let opened = displays_opened();
|
||||
assert_eq!(opened, 1, "the first decoder opened one display");
|
||||
|
||||
// one decoder after another, of every fixture (H.264, HEVC Main, HEVC Main 10)
|
||||
for round in 0..10 {
|
||||
for s in &streams {
|
||||
let Some(mut hw) = hardware(s) else { return };
|
||||
let n = s.samples.len().min(13);
|
||||
let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples[..n]);
|
||||
assert_same(&format!("round {round}"), &decode_all(hw.as_mut(), &s.samples[..n]), &reference);
|
||||
}
|
||||
}
|
||||
assert_eq!(displays_opened(), opened, "decoders one after another open no display");
|
||||
|
||||
// several decoders at once on different threads, each used from its own thread, repeatedly
|
||||
for round in 0..3 {
|
||||
let jobs: Vec<_> = (0..6)
|
||||
.map(|i| {
|
||||
let path = paths[i % paths.len()].clone();
|
||||
std::thread::spawn(move || {
|
||||
let s = read_stream(&path);
|
||||
let Some(mut hw) = hardware(&s) else { return };
|
||||
let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples);
|
||||
assert_same(&format!("round {round}, thread {i}"), &decode_all(hw.as_mut(), &s.samples), &reference);
|
||||
hw.reset();
|
||||
assert_same(&format!("round {round}, thread {i}, after a reset"), &decode_all(hw.as_mut(), &s.samples), &reference);
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for j in jobs {
|
||||
j.join().unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(displays_opened(), opened, "decoders at once on several threads open no display");
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ pub const ALIGN_OPTS: &[&str] = &["Left", "Center", "Right", "Justify"];
|
|||
pub const CAPS_OPTS: &[&str] = &["Normal", "All Caps", "Small Caps"];
|
||||
pub const STROKE_OPTS: &[&str] = &["Outer", "Center", "Inner"];
|
||||
pub const SHAPE_OPTS: &[&str] = &["Rectangle", "Ellipse", "Polygon", "Path"];
|
||||
pub const FILL_KIND_OPTS: &[&str] = &["Solid", "Linear Gradient"];
|
||||
|
||||
/// Whether an effect instance is a graphic layer.
|
||||
pub fn is_layer_id(id: &str) -> bool {
|
||||
|
|
@ -58,6 +59,10 @@ fn appearance() -> Vec<ParamDef> {
|
|||
vec![
|
||||
bo("fill", "Fill", true, A),
|
||||
co("fill_color", "Fill Color", [1.0, 1.0, 1.0, 1.0], A),
|
||||
chc("fill_kind", "Fill Type", FILL_KIND_OPTS, 0, A),
|
||||
co("gradient_start", "Gradient Start", [1.0, 1.0, 1.0, 1.0], A),
|
||||
co("gradient_end", "Gradient End", [0.15, 0.15, 0.15, 1.0], A),
|
||||
p("gradient_angle", "Gradient Angle", ParamKind::Angle, ParamValue::Float(0.0), true, A),
|
||||
bo("stroke", "Stroke", false, A),
|
||||
co("stroke_color", "Stroke Color", [0.0, 0.0, 0.0, 1.0], A),
|
||||
fl("stroke_width", "Stroke Width", 4.0, (0.0, 1000.0), (0.0, 100.0), "", A),
|
||||
|
|
@ -144,10 +149,22 @@ pub(crate) fn layer_defs() -> Vec<EffectDef> {
|
|||
]
|
||||
}
|
||||
|
||||
/// A linear fill across the layer's local bounds. Colours are sRGB-encoded straight RGBA.
|
||||
/// Angle is degrees clockwise on screen (y down); 0 runs left to right.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LinearGradient {
|
||||
pub start: [f32; 4],
|
||||
pub end: [f32; 4],
|
||||
pub angle: f32,
|
||||
}
|
||||
|
||||
/// Fill / strokes / background / shadow of a layer (colours are sRGB-encoded straight RGBA).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Appearance {
|
||||
pub fill: Option<[f32; 4]>,
|
||||
/// Set when Fill Type is Linear Gradient. Replaces the solid fill. A text run with its own
|
||||
/// fill colour stays solid; the other characters keep the ramp.
|
||||
pub gradient: Option<LinearGradient>,
|
||||
/// (colour, width px, type: 0 outer, 1 centre, 2 inner), outermost last.
|
||||
pub strokes: Vec<([f32; 4], f32, u32)>,
|
||||
/// (colour with opacity in alpha, padding px, corner radius px).
|
||||
|
|
@ -334,7 +351,17 @@ pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option<Laye
|
|||
uid: e.layer.as_ref().map_or(0, |x| x.uid),
|
||||
pin: e.layer.as_ref().and_then(|x| x.pin.clone()).filter(|p| p.any()),
|
||||
content,
|
||||
appearance: Appearance { fill: bb(e, "fill", t).then(|| cc(e, "fill_color", t)), strokes, background, shadow },
|
||||
appearance: Appearance {
|
||||
fill: bb(e, "fill", t).then(|| cc(e, "fill_color", t)),
|
||||
gradient: (bb(e, "fill", t) && ch(e, "fill_kind", t) == 1).then(|| LinearGradient {
|
||||
start: cc(e, "gradient_start", t),
|
||||
end: cc(e, "gradient_end", t),
|
||||
angle: ff(e, "gradient_angle", t),
|
||||
}),
|
||||
strokes,
|
||||
background,
|
||||
shadow,
|
||||
},
|
||||
transform: LayerTransform {
|
||||
position,
|
||||
anchor: vv(e, "anchor", t),
|
||||
|
|
@ -457,8 +484,17 @@ mod tests {
|
|||
assert_eq!(t.size, 72.0);
|
||||
assert_eq!(t.font, "Inter");
|
||||
assert_eq!(s.appearance.fill, Some([1.0, 0.0, 0.0, 1.0]));
|
||||
assert!(s.appearance.gradient.is_none(), "solid fill stays solid");
|
||||
assert_eq!(s.transform.position, Vec2::new(100.0, 200.0));
|
||||
assert!(s.appearance.strokes.is_empty() && s.appearance.shadow.is_none());
|
||||
e.params.get_mut("fill_kind").unwrap().value = ParamValue::Choice(1);
|
||||
e.params.get_mut("gradient_start").unwrap().value = ParamValue::Color([1.0, 0.0, 0.0, 1.0]);
|
||||
e.params.get_mut("gradient_end").unwrap().value = ParamValue::Color([0.0, 0.0, 1.0, 1.0]);
|
||||
e.params.get_mut("gradient_angle").unwrap().value = ParamValue::Float(90.0);
|
||||
let g = eval_layer(&e, Tick::ZERO, (1920, 1080)).unwrap().appearance.gradient.expect("linear fill");
|
||||
assert_eq!(g.start, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(g.end, [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(g.angle, 90.0);
|
||||
assert!(is_layer(&e));
|
||||
assert_eq!(layer_display_name(&e, 0), "Hello");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1232,9 +1232,9 @@ impl SequenceView {
|
|||
}
|
||||
}
|
||||
|
||||
/// What was open when a project was saved: the Timeline's sequence tabs in order, the active one
|
||||
/// and how each sequence was shown. Stored beside the project in its file, not in it: it is not
|
||||
/// part of the edit and never an undo step.
|
||||
/// What was open when a project was saved: the Timeline's sequence tabs in order, the active one,
|
||||
/// how each sequence was shown and where its playhead was. Stored beside the project in its file,
|
||||
/// not in it: it is not part of the edit and never an undo step.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProjectView {
|
||||
#[serde(default)]
|
||||
|
|
@ -1243,6 +1243,17 @@ pub struct ProjectView {
|
|||
pub active_sequence: Option<ItemId>,
|
||||
#[serde(default)]
|
||||
pub sequences: BTreeMap<ItemId, SequenceView>,
|
||||
/// Each sequence's playhead, so a sequence reopens where it was left. Files
|
||||
/// written before it existed have none, and every playhead starts at zero as it did then.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub playheads: BTreeMap<ItemId, Tick>,
|
||||
}
|
||||
|
||||
impl ProjectView {
|
||||
/// The latest playhead a project file may ask for: ten million seconds (about 115 days), the
|
||||
/// same bound as a sequence view's scroll position. Playheads are read from project files, so
|
||||
/// nothing in them is trusted.
|
||||
pub const MAX_PLAYHEAD: Tick = Tick(10_000_000 * TICKS_PER_SECOND);
|
||||
}
|
||||
|
||||
/// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:<id>`.
|
||||
|
|
|
|||
|
|
@ -66,19 +66,6 @@ pub(crate) fn mapping(id: &str) -> Option<Mapping> {
|
|||
d.set_param("mix", f(e, "mix", t));
|
||||
},
|
||||
},
|
||||
"parametric_eq" => Mapping {
|
||||
dsp: "simple_eq",
|
||||
preroll: |_, _| 0.05,
|
||||
apply: |d, e, t| {
|
||||
d.set_param("low_freq", f(e, "low_freq", t));
|
||||
d.set_param("low", f(e, "low_gain", t));
|
||||
d.set_param("mid_freq", f(e, "mid_freq", t));
|
||||
d.set_param("mid", f(e, "mid_gain", t));
|
||||
d.set_param("mid_q", f(e, "mid_q", t));
|
||||
d.set_param("high_freq", f(e, "high_freq", t));
|
||||
d.set_param("high", f(e, "high_gain", t));
|
||||
},
|
||||
},
|
||||
// Single-band filters via band 1 of the parametric EQ (types: see FILTER_TYPE_NAMES).
|
||||
"highpass" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| band(d, 4.0, f(e, "cutoff", t), 0.707) },
|
||||
"lowpass" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| band(d, 3.0, f(e, "cutoff", t), 0.707) },
|
||||
|
|
@ -474,4 +461,25 @@ mod tests {
|
|||
assert_eq!(latency("fft_filter", 48000), 2048);
|
||||
assert_eq!(latency("amplify", 48000), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parametric_eq_applies_every_control() {
|
||||
let def = filmcraft_project::find_effect("parametric_eq").unwrap();
|
||||
let resp = |id: &str, v: filmcraft_project::ParamValue, extra: Option<(&str, filmcraft_project::ParamValue)>| {
|
||||
let mut inst = def.instance();
|
||||
inst.param_mut(id).unwrap().value = v;
|
||||
if let Some((k, x)) = extra {
|
||||
inst.param_mut(k).unwrap().value = x;
|
||||
}
|
||||
configured(&inst, Tick::ZERO, 48000).unwrap().response_db(1000.0).unwrap()
|
||||
};
|
||||
use filmcraft_project::ParamValue::{Bool, Float};
|
||||
assert!((resp("master_gain", Float(-30.0), None) + 30.0).abs() < 1.0);
|
||||
for b in ["b1", "b2", "b4", "b5"] {
|
||||
let g = resp(&format!("{b}_gain"), Float(-24.0), Some((&format!("{b}_freq"), Float(1000.0))));
|
||||
assert!((g + 24.0).abs() < 1.0, "{b}: {g}");
|
||||
}
|
||||
assert!(resp("hp_on", Bool(true), Some(("hp_freq", Float(10000.0)))) < -20.0);
|
||||
assert!(resp("lp_on", Bool(true), Some(("lp_freq", Float(100.0)))) < -20.0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use std::hash::{Hash, Hasher};
|
|||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use filmcraft_geom::{Affine, Vec2};
|
||||
use filmcraft_project::graphic::{LayerContent, LayerSpec, ShapeProps, TextProps, eval_layer};
|
||||
use filmcraft_project::graphic::{LayerContent, LayerSpec, LinearGradient, ShapeProps, TextProps, eval_layer};
|
||||
use filmcraft_project::graphic_design::{GraphicMeta, PinNode, remap_time, resolve_pins, roll_offset};
|
||||
use filmcraft_project::{EffectInstance, TrackItem};
|
||||
use filmcraft_text::raster::{Xform, apply, fill_into, xform_scale};
|
||||
|
|
@ -20,7 +20,7 @@ use filmcraft_text::{Align, Caps, Layout, Mask, ParagraphStyle, StrokeKind, Styl
|
|||
use filmcraft_time::Tick;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::graphics::premul_linear;
|
||||
use crate::graphics::{premul_linear, premul_linear_lerp};
|
||||
use crate::image::Image;
|
||||
|
||||
/// f64 affine → the text crate's f32 transform.
|
||||
|
|
@ -191,6 +191,109 @@ fn over_mask(px: &mut [f32], m: &Mask, c: [f32; 4]) {
|
|||
});
|
||||
}
|
||||
|
||||
fn invert_xform(m: &Xform) -> Option<Xform> {
|
||||
let det = m[0] * m[3] - m[1] * m[2];
|
||||
if det.abs() < 1e-8 {
|
||||
return None;
|
||||
}
|
||||
let inv = 1.0 / det;
|
||||
let a = m[3] * inv;
|
||||
let b = -m[1] * inv;
|
||||
let c = -m[2] * inv;
|
||||
let d = m[0] * inv;
|
||||
Some([a, b, c, d, -(a * m[4] + c * m[5]), -(b * m[4] + d * m[5])])
|
||||
}
|
||||
|
||||
/// Paint `grad` through `msk`. 0° runs left to right across `lb` in layer space; the angle is
|
||||
/// clockwise on screen and turns with `inv` (the device→layer transform).
|
||||
fn over_linear_gradient(px: &mut [f32], msk: &Mask, grad: &LinearGradient, lb: [f32; 4], inv: &Xform, origin: (i32, i32)) {
|
||||
if msk.w == 0 {
|
||||
return;
|
||||
}
|
||||
let (cx, cy) = ((lb[0] + lb[2]) * 0.5, (lb[1] + lb[3]) * 0.5);
|
||||
let (sin, cos) = grad.angle.to_radians().sin_cos();
|
||||
let (ax, ay) = (cos, sin);
|
||||
let corners = [(lb[0], lb[1]), (lb[2], lb[1]), (lb[0], lb[3]), (lb[2], lb[3])];
|
||||
let half = corners.iter().map(|(x, y)| ((x - cx) * ax + (y - cy) * ay).abs()).fold(1e-3_f32, f32::max);
|
||||
let w = msk.w;
|
||||
let (start, end) = (grad.start, grad.end);
|
||||
px.par_chunks_mut(4 * w).enumerate().for_each(|(row, dest)| {
|
||||
let py = origin.1 as f32 + row as f32 + 0.5;
|
||||
let mrow = &msk.a[row * w..(row + 1) * w];
|
||||
for (col, (p, &cov)) in dest.as_chunks_mut::<4>().0.iter_mut().zip(mrow).enumerate() {
|
||||
if cov <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (lx, ly) = apply(inv, origin.0 as f32 + col as f32 + 0.5, py);
|
||||
let t = (((((lx - cx) * ax + (ly - cy) * ay) / half) + 1.0) * 0.5).clamp(0.0, 1.0);
|
||||
let c = premul_linear_lerp(start, end, t);
|
||||
let k = 1.0 - c[3] * cov;
|
||||
for i in 0..4 {
|
||||
p[i] = c[i] * cov + p[i] * k;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Solid fill, one mask per distinct colour when text runs carry their own.
|
||||
fn paint_solid(px: &mut [f32], cover: &Mask, color: [f32; 4], text: Option<&Layout>, m: &Xform, run_fills: &[Option<[f32; 4]>], origin: (i32, i32)) {
|
||||
let Some(layout) = text.filter(|_| run_fills.iter().any(Option::is_some)) else {
|
||||
over_mask(px, cover, premul_linear(color));
|
||||
return;
|
||||
};
|
||||
let mut groups: Vec<([f32; 4], Vec<u16>)> = Vec::new();
|
||||
for r in 0..=run_fills.len() {
|
||||
let col = if r == 0 { color } else { run_fills[r - 1].unwrap_or(color) };
|
||||
match groups.iter_mut().find(|g| g.0 == col) {
|
||||
Some(g) => g.1.push(r as u16),
|
||||
None => groups.push((col, vec![r as u16])),
|
||||
}
|
||||
}
|
||||
for (col, runs) in groups {
|
||||
let mut m_run = Mask::new(cover.w, cover.h);
|
||||
for r in runs {
|
||||
render::draw_run(layout, m, &mut m_run, origin, Some(r));
|
||||
}
|
||||
over_mask(px, &m_run, premul_linear(col));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient for runs that have no fill of their own; runs with a fill stay that solid colour.
|
||||
fn paint_mixed_gradient(
|
||||
px: &mut [f32],
|
||||
cover: &Mask,
|
||||
grad: &LinearGradient,
|
||||
lb: [f32; 4],
|
||||
inv: &Xform,
|
||||
origin: (i32, i32),
|
||||
layout: &Layout,
|
||||
m: &Xform,
|
||||
run_fills: &[Option<[f32; 4]>],
|
||||
) {
|
||||
let mut grad_mask = Mask::new(cover.w, cover.h);
|
||||
render::draw_run(layout, m, &mut grad_mask, origin, Some(0));
|
||||
let mut solids: Vec<([f32; 4], Vec<u16>)> = Vec::new();
|
||||
for (i, fill) in run_fills.iter().enumerate() {
|
||||
let run = (i + 1) as u16;
|
||||
if let Some(col) = fill {
|
||||
match solids.iter_mut().find(|g| g.0 == *col) {
|
||||
Some(g) => g.1.push(run),
|
||||
None => solids.push((*col, vec![run])),
|
||||
}
|
||||
} else {
|
||||
render::draw_run(layout, m, &mut grad_mask, origin, Some(run));
|
||||
}
|
||||
}
|
||||
over_linear_gradient(px, &grad_mask, grad, lb, inv, origin);
|
||||
for (col, runs) in solids {
|
||||
let mut m_run = Mask::new(cover.w, cover.h);
|
||||
for r in runs {
|
||||
render::draw_run(layout, m, &mut m_run, origin, Some(r));
|
||||
}
|
||||
over_mask(px, &m_run, premul_linear(col));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterise one layer with layer→device transform `m`, clipped to a `cw`×`ch` canvas.
|
||||
pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option<LayerRaster> {
|
||||
if !spec.enabled || spec.transform.opacity <= 0.0 {
|
||||
|
|
@ -294,32 +397,22 @@ pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option
|
|||
over_mask(&mut px, sm, premul_linear(*c));
|
||||
}
|
||||
}
|
||||
if let Some(c) = ap.fill {
|
||||
// per-character fills: each style run with its own colour is drawn on its own
|
||||
let run_fills: Vec<Option<[f32; 4]>> = match &spec.content {
|
||||
LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
match &text {
|
||||
Some(l) if run_fills.iter().any(Option::is_some) => {
|
||||
let mut groups: Vec<([f32; 4], Vec<u16>)> = Vec::new();
|
||||
for r in 0..=run_fills.len() {
|
||||
let col = if r == 0 { c } else { run_fills[r - 1].unwrap_or(c) };
|
||||
match groups.iter_mut().find(|g| g.0 == col) {
|
||||
Some(g) => g.1.push(r as u16),
|
||||
None => groups.push((col, vec![r as u16])),
|
||||
}
|
||||
}
|
||||
for (col, runs) in groups {
|
||||
let mut m_run = Mask::new(w, h);
|
||||
for r in runs {
|
||||
render::draw_run(l, m, &mut m_run, (x0, y0), Some(r));
|
||||
}
|
||||
over_mask(&mut px, &m_run, premul_linear(col));
|
||||
}
|
||||
}
|
||||
_ => over_mask(&mut px, &cover, premul_linear(c)),
|
||||
let run_fills: Vec<Option<[f32; 4]>> = match &spec.content {
|
||||
LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if let Some(grad) = &ap.gradient
|
||||
&& let Some(inv) = invert_xform(m)
|
||||
{
|
||||
if run_fills.iter().any(Option::is_some)
|
||||
&& let Some(layout) = &text
|
||||
{
|
||||
paint_mixed_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0), layout, m, &run_fills);
|
||||
} else {
|
||||
over_linear_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0));
|
||||
}
|
||||
} else if let Some(c) = ap.fill {
|
||||
paint_solid(&mut px, &cover, c, text.as_deref(), m, &run_fills, (x0, y0));
|
||||
}
|
||||
for ((sm, c), (_, _, k)) in strokes.iter().zip(&ap.strokes).rev() {
|
||||
if !outer(*k) {
|
||||
|
|
@ -702,6 +795,103 @@ mod tests {
|
|||
assert_eq!(img.get(20, 20)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_gradient_runs_left_to_right_on_a_shape_and_on_text() {
|
||||
let mut shape = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]);
|
||||
set(&mut shape, "fill_kind", ParamValue::Choice(1));
|
||||
set(&mut shape, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
|
||||
set(&mut shape, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
|
||||
set(&mut shape, "gradient_angle", ParamValue::Float(0.0));
|
||||
let mut img = Image::new(200, 200);
|
||||
render_graphic_layers(&[shape], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img);
|
||||
let left = img.get(70, 100);
|
||||
let right = img.get(130, 100);
|
||||
assert!(left[3] > 0.9 && left[0] > left[2], "redder on the left {left:?}");
|
||||
assert!(right[3] > 0.9 && right[2] > right[0], "bluer on the right {right:?}");
|
||||
|
||||
let mut text = new_text_layer("HHHHHHHH", Vec2::new(16.0, 90.0), 64.0);
|
||||
set(&mut text, "fill_kind", ParamValue::Choice(1));
|
||||
set(&mut text, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
|
||||
set(&mut text, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
|
||||
let mut img = Image::new(500, 180);
|
||||
render_graphic_layers(&[text], Tick::ZERO, (500, 180), &Affine::IDENTITY, &mut img);
|
||||
let ink: Vec<(usize, usize)> = (0..img.h).flat_map(|y| (0..img.w).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.8).collect();
|
||||
let minx = ink.iter().map(|p| p.0).min().unwrap();
|
||||
let maxx = ink.iter().map(|p| p.0).max().unwrap();
|
||||
let span = (maxx - minx).max(1);
|
||||
let (mut ln, mut rn, mut ls, mut rs) = (0.0_f32, 0.0, 0.0, 0.0);
|
||||
for &(x, y) in &ink {
|
||||
let p = img.get(x, y)[0];
|
||||
if x < minx + span / 3 {
|
||||
ls += p;
|
||||
ln += 1.0;
|
||||
} else if x > maxx - span / 3 {
|
||||
rs += p;
|
||||
rn += 1.0;
|
||||
}
|
||||
}
|
||||
assert!(ln > 30.0 && rn > 30.0, "ink on both ends of the title: {ln} {rn} x {minx}..{maxx}");
|
||||
assert!(ls / ln > rs / rn + 0.15, "text ramps from red to blue: {} {}", ls / ln, rs / rn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_gradient_blends_in_linear_light_and_turns_with_the_layer() {
|
||||
let mut shape = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]);
|
||||
set(&mut shape, "fill_kind", ParamValue::Choice(1));
|
||||
set(&mut shape, "gradient_start", ParamValue::Color([0.0, 0.0, 0.0, 1.0]));
|
||||
set(&mut shape, "gradient_end", ParamValue::Color([1.0, 1.0, 1.0, 1.0]));
|
||||
let mut img = Image::new(200, 200);
|
||||
render_graphic_layers(&[shape.clone()], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img);
|
||||
let mid = img.get(100, 100)[0];
|
||||
// Linear-light midpoint is ~0.5. An sRGB midpoint converted to linear is ~0.21.
|
||||
assert!(mid > 0.4 && mid < 0.6, "centre of black→white is linear 0.5, got {mid}");
|
||||
assert!(img.get(70, 100)[0] < mid && mid < img.get(130, 100)[0]);
|
||||
|
||||
set(&mut shape, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
|
||||
set(&mut shape, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
|
||||
set(&mut shape, "rotation", ParamValue::Float(90.0));
|
||||
let mut turned = Image::new(200, 200);
|
||||
render_graphic_layers(&[shape], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut turned);
|
||||
let top = turned.get(100, 70);
|
||||
let bot = turned.get(100, 130);
|
||||
assert!(top[3] > 0.9 && bot[3] > 0.9, "the rotated ramp is inside the shape: {top:?} {bot:?}");
|
||||
assert!(top[0] > top[2], "clockwise 90° carries the start colour to the top {top:?}");
|
||||
assert!(bot[2] > bot[0], "and the end colour to the bottom {bot:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_gradient_keeps_a_per_character_fill_solid() {
|
||||
use filmcraft_project::graphic_design::{CharStyle, LayerExtra, StyleRun};
|
||||
let mut e = new_text_layer("AAAA", Vec2::new(20.0, 100.0), 60.0);
|
||||
set(&mut e, "fill_kind", ParamValue::Choice(1));
|
||||
set(&mut e, "gradient_start", ParamValue::Color([0.0, 1.0, 0.0, 1.0]));
|
||||
set(&mut e, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
|
||||
let red = CharStyle { fill: Some([1.0, 0.0, 0.0, 1.0]), ..Default::default() };
|
||||
e.layer = Some(Box::new(LayerExtra { runs: vec![StyleRun { start: 2, end: 4, style: red }], ..Default::default() }));
|
||||
let mut img = Image::new(400, 200);
|
||||
render_graphic_layers(&[e.clone()], Tick::ZERO, (400, 200), &Affine::IDENTITY, &mut img);
|
||||
let l = text_layout(match &eval_layer(&e, Tick::ZERO, (400, 200)).unwrap().content {
|
||||
LayerContent::Text(t) => t,
|
||||
_ => unreachable!(),
|
||||
});
|
||||
let split = 20.0 + l.caret(2).0;
|
||||
let (mut green, mut red_px) = (0, 0);
|
||||
for y in 0..200 {
|
||||
for x in 0..400 {
|
||||
let p = img.get(x, y);
|
||||
if p[3] < 0.8 {
|
||||
continue;
|
||||
}
|
||||
if (x as f32) < split - 1.0 && p[1] > p[0] && p[1] > p[2] {
|
||||
green += 1;
|
||||
} else if (x as f32) > split + 1.0 && p[0] > 0.9 && p[1] < 0.05 {
|
||||
red_px += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(green > 80 && red_px > 80, "gradient on the plain letters, solid red on the styled ones: {green} {red_px}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_and_centre_strokes() {
|
||||
for (kind, inside, outside) in [(2u32, true, false), (1, true, true)] {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@ pub fn premul_linear(c: [f32; 4]) -> [f32; 4] {
|
|||
[srgb_to_linear(c[0]) * a, srgb_to_linear(c[1]) * a, srgb_to_linear(c[2]) * a, a]
|
||||
}
|
||||
|
||||
/// Blend two sRGB straight colours in linear light, then premultiply. `t` is 0 at `start`.
|
||||
pub fn premul_linear_lerp(start: [f32; 4], end: [f32; 4], t: f32) -> [f32; 4] {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let s = [srgb_to_linear(start[0]), srgb_to_linear(start[1]), srgb_to_linear(start[2]), start[3].clamp(0.0, 1.0)];
|
||||
let e = [srgb_to_linear(end[0]), srgb_to_linear(end[1]), srgb_to_linear(end[2]), end[3].clamp(0.0, 1.0)];
|
||||
let a = s[3] * (1.0 - t) + e[3] * t;
|
||||
[(s[0] * (1.0 - t) + e[0] * t) * a, (s[1] * (1.0 - t) + e[1] * t) * a, (s[2] * (1.0 - t) + e[2] * t) * a, a]
|
||||
}
|
||||
|
||||
/// Composite `color` (premultiplied linear) through coverage `m` placed at (`x0`, `y0`), scaled by
|
||||
/// `opacity`, over `img`.
|
||||
pub fn paint_mask(img: &mut Image, m: &Mask, x0: i32, y0: i32, color: [f32; 4], opacity: f32) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
|
|||
pub const TICKS_PER_SECOND: i64 = 254_016_000_000;
|
||||
|
||||
/// A point or duration in time, in ticks.
|
||||
/// Arithmetic saturates at the i64 storage limits; representable results remain exact.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Tick(pub i64);
|
||||
|
|
@ -37,20 +38,26 @@ impl Tick {
|
|||
}
|
||||
/// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000).
|
||||
pub fn from_units(units: i64, per_second: i64) -> Tick {
|
||||
Tick((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0) as i64)
|
||||
Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0)))
|
||||
}
|
||||
/// Floor conversion to a count of units at `per_second`.
|
||||
pub fn to_units_floor(self, per_second: i64) -> i64 {
|
||||
(self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128) as i64
|
||||
clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128))
|
||||
}
|
||||
/// Conversion from a rational timestamp `pts * num / den` seconds (container timebases).
|
||||
pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick {
|
||||
let t = pts as i128 * num as i128 * TICKS_PER_SECOND as i128;
|
||||
Tick(t.checked_div_euclid(den as i128).unwrap_or(0) as i64)
|
||||
if den == 0 {
|
||||
return Tick::ZERO;
|
||||
}
|
||||
let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else {
|
||||
// A product beyond i128 divided by an i64 cannot fit back into i64.
|
||||
return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX });
|
||||
};
|
||||
Tick(clamp_i64(t.div_euclid(den as i128)))
|
||||
}
|
||||
/// Inverse of [`Tick::from_rational`], floored to the timebase.
|
||||
pub fn to_rational_floor(self, num: i64, den: i64) -> i64 {
|
||||
(self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0) as i64
|
||||
clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0))
|
||||
}
|
||||
/// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up).
|
||||
///
|
||||
|
|
@ -63,10 +70,10 @@ impl Tick {
|
|||
let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) };
|
||||
let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 };
|
||||
// 0 <= r < unit: round up from the halfway point
|
||||
(if r * 2 >= unit { q + 1 } else { q }) as i64
|
||||
clamp_i64(if r * 2 >= unit { q + 1 } else { q })
|
||||
}
|
||||
pub fn abs(self) -> Tick {
|
||||
Tick(self.0.abs())
|
||||
Tick(self.0.saturating_abs())
|
||||
}
|
||||
pub fn min(self, o: Tick) -> Tick {
|
||||
Tick(self.0.min(o.0))
|
||||
|
|
@ -80,36 +87,36 @@ impl Tick {
|
|||
}
|
||||
/// Scale by a rational factor (`num/den`), flooring.
|
||||
pub fn mul_ratio(self, num: i64, den: i64) -> Tick {
|
||||
Tick((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0) as i64)
|
||||
Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Tick {
|
||||
type Output = Tick;
|
||||
fn add(self, o: Tick) -> Tick {
|
||||
Tick(self.0 + o.0)
|
||||
Tick(self.0.saturating_add(o.0))
|
||||
}
|
||||
}
|
||||
impl Sub for Tick {
|
||||
type Output = Tick;
|
||||
fn sub(self, o: Tick) -> Tick {
|
||||
Tick(self.0 - o.0)
|
||||
Tick(self.0.saturating_sub(o.0))
|
||||
}
|
||||
}
|
||||
impl Neg for Tick {
|
||||
type Output = Tick;
|
||||
fn neg(self) -> Tick {
|
||||
Tick(-self.0)
|
||||
Tick(self.0.saturating_neg())
|
||||
}
|
||||
}
|
||||
impl AddAssign for Tick {
|
||||
fn add_assign(&mut self, o: Tick) {
|
||||
self.0 += o.0;
|
||||
self.0 = self.0.saturating_add(o.0);
|
||||
}
|
||||
}
|
||||
impl SubAssign for Tick {
|
||||
fn sub_assign(&mut self, o: Tick) {
|
||||
self.0 -= o.0;
|
||||
self.0 = self.0.saturating_sub(o.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,8 +195,8 @@ impl FrameRate {
|
|||
];
|
||||
|
||||
pub fn new(num: i64, den: i64) -> Self {
|
||||
let g = gcd(num.abs(), den.abs()).max(1);
|
||||
FrameRate { num: num / g, den: den / g }
|
||||
let g = i128::from(gcd(num.unsigned_abs(), den.unsigned_abs()).max(1));
|
||||
FrameRate { num: (i128::from(num) / g) as i64, den: (i128::from(den) / g) as i64 }
|
||||
}
|
||||
|
||||
/// Closest standard rate for a float (e.g. from a container's average rate).
|
||||
|
|
@ -213,49 +220,52 @@ impl FrameRate {
|
|||
}
|
||||
|
||||
/// Exact duration of one frame (rounded down only for exotic rates).
|
||||
/// Values outside the integer tick range saturate at its limit.
|
||||
pub fn frame_duration(self) -> Tick {
|
||||
let r = self.sane();
|
||||
Tick(((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128) as i64)
|
||||
Tick(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128))
|
||||
}
|
||||
|
||||
/// Index of the frame containing `t` (floor).
|
||||
/// Index of the frame containing `t` (floor), saturated to the integer frame range.
|
||||
pub fn frame_at(self, t: Tick) -> i64 {
|
||||
let r = self.sane();
|
||||
(t.0 as i128 * r.num as i128).div_euclid(TICKS_PER_SECOND as i128 * r.den as i128) as i64
|
||||
clamp_i64(frame_index(self.sane(), t))
|
||||
}
|
||||
|
||||
/// Start tick of frame `f` (ceiling division).
|
||||
///
|
||||
/// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every
|
||||
/// frame index, even when the frame duration is not an integer number of ticks
|
||||
/// representable frame boundary with at least one tick per frame, even when the frame duration is not an integer number of ticks
|
||||
/// (exotic rates like 37.516 fps where `TICKS_PER_SECOND * den / num` has a
|
||||
/// fractional remainder). Floor division truncates the remainder, causing each
|
||||
/// frame boundary to drift backward by one tick until `frame_at` returns `f - 1`
|
||||
/// instead of `f` — frame stepping then stalls or moves backward.
|
||||
/// Ceiling is correct because the frame duration is always > 1 tick, so
|
||||
/// `floor(ceil(f·D) / D) == f` for every integer `f`.
|
||||
/// Values outside the integer tick range saturate at its limit.
|
||||
pub fn tick_of(self, f: i64) -> Tick {
|
||||
let r = self.sane();
|
||||
let n = f as i128 * TICKS_PER_SECOND as i128 * r.den as i128;
|
||||
Tick((-(-n).div_euclid(r.num as i128)) as i64)
|
||||
Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f))))
|
||||
}
|
||||
|
||||
/// Snap `t` down to a frame boundary.
|
||||
pub fn snap(self, t: Tick) -> Tick {
|
||||
self.tick_of(self.frame_at(t))
|
||||
let r = self.sane();
|
||||
Tick(clamp_i64(frame_boundary(r, frame_index(r, t))))
|
||||
}
|
||||
|
||||
/// Snap `t` to the nearest frame boundary.
|
||||
pub fn snap_nearest(self, t: Tick) -> Tick {
|
||||
let a = self.snap(t);
|
||||
let b = self.tick_of(self.frame_at(t) + 1);
|
||||
if (t - a) <= (b - t) { a } else { b }
|
||||
let r = self.sane();
|
||||
// Keep the frame index and both boundaries wide until choosing the nearest. Saturating
|
||||
// an unrepresentable upper boundary first would make it seem closer than it really is.
|
||||
let f = frame_index(r, t);
|
||||
let a = frame_boundary(r, f);
|
||||
let b = frame_boundary(r, f.saturating_add(1));
|
||||
let t = i128::from(t.0);
|
||||
Tick(clamp_i64(if t - a <= b - t { a } else { b }))
|
||||
}
|
||||
|
||||
/// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976.
|
||||
pub fn timecode_base(self) -> i64 {
|
||||
let r = self.sane();
|
||||
((r.num + r.den - 1) / r.den).max(1)
|
||||
(r.num / r.den).saturating_add(i64::from(r.num % r.den != 0)).max(1)
|
||||
}
|
||||
|
||||
/// NTSC (x/1001) rates can use drop-frame timecode.
|
||||
|
|
@ -286,7 +296,29 @@ impl fmt::Display for FrameRate {
|
|||
}
|
||||
}
|
||||
|
||||
fn gcd(mut a: i64, mut b: i64) -> i64 {
|
||||
/// Narrow only after applying the storage bounds, never by a wrapping integer cast.
|
||||
fn clamp_i64(value: i128) -> i64 {
|
||||
value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
|
||||
}
|
||||
|
||||
/// Callers pass a positive rate. Products of two i64 values fit in i128.
|
||||
fn frame_index(rate: FrameRate, t: Tick) -> i128 {
|
||||
(i128::from(t.0) * i128::from(rate.num)).div_euclid(i128::from(TICKS_PER_SECOND) * i128::from(rate.den))
|
||||
}
|
||||
|
||||
/// Ceiling of the rational boundary. An overflowing numerator divided by an i64 rate cannot
|
||||
/// fit in an i64 tick, so saturating that intermediate preserves the public result. Boundaries
|
||||
/// derived from frame_index remain within i128 (at most one frame away from an i64 tick).
|
||||
fn frame_boundary(rate: FrameRate, frame: i128) -> i128 {
|
||||
let Some(n) = frame.checked_mul(i128::from(TICKS_PER_SECOND)).and_then(|n| n.checked_mul(i128::from(rate.den))) else {
|
||||
return if frame < 0 { i128::MIN } else { i128::MAX };
|
||||
};
|
||||
let d = i128::from(rate.num);
|
||||
// Flooring a fractional boundary puts the tick in the preceding frame.
|
||||
n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)
|
||||
}
|
||||
|
||||
fn gcd(mut a: u64, mut b: u64) -> u64 {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
|
|
@ -329,8 +361,9 @@ fn df_params(rate: FrameRate) -> (i64, i64) {
|
|||
/// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`.
|
||||
pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) {
|
||||
let neg = frame < 0;
|
||||
let mut n = frame.saturating_abs();
|
||||
let mut n = i128::from(frame).abs();
|
||||
let (drop, base) = df_params(rate);
|
||||
let (drop, base) = (i128::from(drop), i128::from(base));
|
||||
if drop_frame && rate.supports_drop_frame() {
|
||||
let per_min = base * 60 - drop;
|
||||
let per_10 = per_min * 10 + drop;
|
||||
|
|
@ -345,18 +378,20 @@ pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool,
|
|||
let s = (n / base) % 60;
|
||||
let mi = (n / (base * 60)) % 60;
|
||||
let h = n / (base * 3600);
|
||||
(neg, h, mi, s, f)
|
||||
(neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f))
|
||||
}
|
||||
|
||||
/// Convert SMPTE fields back to a frame count.
|
||||
pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 {
|
||||
let (drop, base) = df_params(rate);
|
||||
let mut n = h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(s).saturating_mul(base).saturating_add(f);
|
||||
let seconds = i128::from(h) * 3600 + i128::from(m) * 60 + i128::from(s);
|
||||
// Nominal drop-frame labels can exceed i64 even when the final frame count fits.
|
||||
let mut n = seconds.saturating_mul(i128::from(base)).saturating_add(i128::from(f));
|
||||
if drop_frame && rate.supports_drop_frame() {
|
||||
let total_min = h.saturating_mul(60).saturating_add(m);
|
||||
n = n.saturating_sub(drop.saturating_mul(total_min - total_min / 10));
|
||||
let total_min = i128::from(h) * 60 + i128::from(m);
|
||||
n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10));
|
||||
}
|
||||
n
|
||||
clamp_i64(n)
|
||||
}
|
||||
|
||||
/// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame).
|
||||
|
|
@ -377,7 +412,7 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp
|
|||
TimeDisplay::Feet35 | TimeDisplay::Feet16 => {
|
||||
let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 };
|
||||
let neg = frame < 0;
|
||||
let a = frame.abs();
|
||||
let a = frame.unsigned_abs();
|
||||
format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft)
|
||||
}
|
||||
TimeDisplay::AudioSamples => {
|
||||
|
|
@ -511,6 +546,119 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_rates_saturate_without_wrapping_and_keep_representable_values_exact() {
|
||||
let slow = FrameRate { num: 1, den: i64::MAX };
|
||||
assert_eq!(slow.frame_duration(), Tick(i64::MAX));
|
||||
assert_eq!(slow.tick_of(i64::MAX), Tick(i64::MAX));
|
||||
assert_eq!(slow.tick_of(i64::MIN), Tick(i64::MIN));
|
||||
assert_eq!(slow.snap_nearest(Tick(i64::MAX)), Tick::ZERO, "compare the real boundary before saturating");
|
||||
assert_eq!(slow.snap_nearest(Tick(i64::MIN)), Tick::ZERO);
|
||||
let fast = FrameRate { num: i64::MAX, den: 1 };
|
||||
assert_eq!(fast.tick_of(i64::MAX), Tick(TICKS_PER_SECOND), "large factors can cancel exactly");
|
||||
assert_eq!(fast.tick_of(-i64::MAX), Tick(-TICKS_PER_SECOND));
|
||||
assert_eq!(fast.frame_at(Tick(TICKS_PER_SECOND)), i64::MAX);
|
||||
assert_eq!(fast.frame_at(Tick(i64::MAX)), i64::MAX);
|
||||
assert_eq!(fast.frame_at(Tick(i64::MIN)), i64::MIN);
|
||||
assert_eq!(fast.timecode_base(), i64::MAX);
|
||||
for t in [i64::MIN, -TICKS_PER_SECOND, -1, 0, 1, TICKS_PER_SECOND, i64::MAX] {
|
||||
assert_eq!(fast.snap(Tick(t)), Tick(t), "sub-tick frames include every integer tick");
|
||||
assert_eq!(fast.snap_nearest(Tick(t)), Tick(t));
|
||||
}
|
||||
let balanced = FrameRate { num: i64::MAX, den: i64::MAX };
|
||||
assert_eq!(balanced.frame_duration(), Tick(TICKS_PER_SECOND));
|
||||
assert_eq!(balanced.tick_of(2), Tick(2 * TICKS_PER_SECOND));
|
||||
assert_eq!(balanced.frame_at(Tick(2 * TICKS_PER_SECOND)), 2);
|
||||
assert_eq!(balanced.timecode_base(), 1);
|
||||
assert_eq!(FrameRate::new(i64::MIN, i64::MIN), FrameRate { num: -1, den: -1 });
|
||||
assert_eq!(FrameRate::new(i64::MIN, 0), FrameRate { num: -1, den: 0 });
|
||||
assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_time_conversions_saturate_at_storage_limits() {
|
||||
assert_eq!(Tick::from_units(i64::MAX, 1), Tick(i64::MAX));
|
||||
assert_eq!(Tick::from_units(i64::MIN, 1), Tick(i64::MIN));
|
||||
assert_eq!(Tick(i64::MAX).to_units_floor(i64::MAX), i64::MAX);
|
||||
assert_eq!(Tick(i64::MIN).to_units_floor(i64::MAX), i64::MIN);
|
||||
assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 1), Tick(i64::MAX));
|
||||
assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, 1), Tick(i64::MIN));
|
||||
assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, -1), Tick(i64::MAX));
|
||||
assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 0), Tick::ZERO);
|
||||
assert_eq!(Tick(i64::MAX).to_rational_floor(1, i64::MAX), i64::MAX);
|
||||
assert_eq!(Tick(i64::MIN).to_rational_round(1, i64::MAX), i64::MIN);
|
||||
assert_eq!(Tick(i64::MAX).mul_ratio(i64::MAX, 1), Tick(i64::MAX));
|
||||
assert_eq!(Tick(i64::MIN).abs(), Tick(i64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_fields_keep_cancelling_terms_wide_at_storage_limits() {
|
||||
for rate in [FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] {
|
||||
for frame in [0, 1, 999_999, i64::MAX - 1, i64::MAX] {
|
||||
let (negative, h, m, s, f) = frames_to_fields(frame, rate, true);
|
||||
assert!(!negative);
|
||||
assert_eq!(fields_to_frames(h, m, s, f, rate, true), frame, "{rate}: {frame}");
|
||||
assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), -frame);
|
||||
}
|
||||
let (negative, h, m, s, f) = frames_to_fields(i64::MIN, rate, true);
|
||||
assert!(negative);
|
||||
assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), i64::MIN);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_arithmetic_saturates_only_outside_storage_limits() {
|
||||
let values = [i64::MIN, i64::MIN + 1, Tick::MIN.0, -1, 0, 1, Tick::MAX.0, i64::MAX - 1, i64::MAX];
|
||||
for a in values {
|
||||
assert_eq!(-Tick(a), Tick(clamp_i64(-i128::from(a))));
|
||||
for b in values {
|
||||
let sum = Tick(clamp_i64(i128::from(a) + i128::from(b)));
|
||||
let difference = Tick(clamp_i64(i128::from(a) - i128::from(b)));
|
||||
assert_eq!(Tick(a) + Tick(b), sum);
|
||||
assert_eq!(Tick(a) - Tick(b), difference);
|
||||
let mut assigned = Tick(a);
|
||||
assigned += Tick(b);
|
||||
assert_eq!(assigned, sum);
|
||||
assigned = Tick(a);
|
||||
assigned -= Tick(b);
|
||||
assert_eq!(assigned, difference);
|
||||
}
|
||||
}
|
||||
assert_eq!(Tick::MAX + Tick(1), Tick(Tick::MAX.0 + 1), "project bounds are not storage bounds");
|
||||
assert_eq!(Tick::MIN - Tick(1), Tick(Tick::MIN.0 - 1));
|
||||
assert_eq!(TimeRange::new(Tick(i64::MAX), Tick(1)).end(), Tick(i64::MAX));
|
||||
assert_eq!(TimeRange::new(Tick(i64::MIN), Tick(-1)).end(), Tick(i64::MIN));
|
||||
assert_eq!(TimeRange::from_bounds(Tick(i64::MIN), Tick(i64::MAX)).duration, Tick(i64::MAX));
|
||||
assert_eq!(TimeRange::from_bounds(Tick(i64::MAX), Tick(i64::MIN)).duration, Tick(i64::MIN));
|
||||
let ordinary = TimeRange::from_bounds(Tick::MIN, Tick::MAX);
|
||||
assert_eq!(ordinary.end(), Tick::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_limits_are_total_across_frame_conversion_and_display() {
|
||||
let values = [i64::MIN, -i64::MAX, -1, 0, 1, TICKS_PER_SECOND, i64::MAX];
|
||||
for num in values {
|
||||
for den in values {
|
||||
let r = FrameRate { num, den };
|
||||
let _ = FrameRate::new(num, den);
|
||||
let _ = r.frame_duration();
|
||||
assert!(r.timecode_base() >= 1);
|
||||
for value in values {
|
||||
let tick = Tick(value);
|
||||
let _ = r.tick_of(value);
|
||||
let _ = r.frame_at(tick);
|
||||
let _ = r.snap(tick);
|
||||
let _ = r.snap_nearest(tick);
|
||||
for display in TimeDisplay::ALL {
|
||||
let _ = format_time(tick, r, true, display, 48_000);
|
||||
}
|
||||
let (_, h, m, sec, f) = frames_to_fields(value, r, true);
|
||||
let _ = fields_to_frames(h, m, sec, f, r, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_common_rate_is_exact() {
|
||||
for r in FrameRate::COMMON {
|
||||
|
|
@ -660,6 +808,18 @@ mod tests {
|
|||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn extreme_frame_math_stays_monotonic(num in 1i64..=i64::MAX, den in 1i64..=i64::MAX, a in any::<i64>(), b in any::<i64>()) {
|
||||
let r = FrameRate { num, den };
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
prop_assert!(r.tick_of(lo) <= r.tick_of(hi));
|
||||
prop_assert!(r.frame_at(Tick(lo)) <= r.frame_at(Tick(hi)));
|
||||
prop_assert!(r.snap(Tick(lo)) <= r.snap(Tick(hi)));
|
||||
prop_assert!(r.snap_nearest(Tick(lo)) <= r.snap_nearest(Tick(hi)));
|
||||
let snapped = r.snap_nearest(Tick(a));
|
||||
prop_assert_eq!(r.snap_nearest(snapped), snapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_tick_roundtrip(f in -1_000_000i64..10_000_000, ri in 0usize..11) {
|
||||
let r = FrameRate::COMMON[ri];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@
|
|||
The egui frontend draws panels and dialogs, and dispatches project changes through the engine.
|
||||
Interactive controls keep stable automation ids regardless of the interface language.
|
||||
|
||||
## Transition drop previews
|
||||
|
||||
Dragging a video or audio transition over a compatible clip shows its actual time span, the
|
||||
selected cut and an In/Out label. Shared cuts center the span; isolated edges use a one-sided
|
||||
span. The preview uses the engine's read-only transition planner, including Timeline duration
|
||||
preferences, and release dispatches `effects.apply` with the same edge. Locked tracks and
|
||||
incompatible clip types show no valid transition preview. Ordinary effects retain their clip
|
||||
outline. `timeline.transitionDropPreview` exposes the span and edge to automation.
|
||||
|
||||
`transition_drop_ui` covers both edges of short video/audio clips, hover without project edits,
|
||||
the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screenshots with wgpu.
|
||||
|
||||
## Localisation
|
||||
|
||||
Edit > Language offers English, Japanese and Spanish. The language is stored in the engine's
|
||||
|
|
@ -19,10 +31,10 @@ fills translated templates, and `i18n::t` translates names from registries. Plac
|
|||
(including user filenames containing braces) are inserted literally. Catalog translations are
|
||||
original work using ordinary language, without proprietary localisation resources.
|
||||
|
||||
Spanish covers menus, panels, dialogs, settings and registry labels. Searches accept both the
|
||||
Spanish and Japanese cover menus, panels, dialogs, settings and registry labels. Searches accept both the
|
||||
translated label and its English source, including Unicode capitals. Project content, command ids
|
||||
and preference values retain their original values. Engine errors, CLI and MCP messages remain
|
||||
English; Japanese currently covers core menus and falls back to English elsewhere.
|
||||
English; Brazilian Portuguese currently covers core menus and falls back to English elsewhere.
|
||||
|
||||
Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders,
|
||||
literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual
|
||||
|
|
|
|||
|
|
@ -973,6 +973,8 @@
|
|||
Edit… Editar…
|
||||
Show graphs Mostrar gráficos
|
||||
Toggle animation Activar/desactivar animación
|
||||
Eyedropper (pick a color from the Program monitor) Cuentagotas (tomar un color del monitor de programa)
|
||||
Eyedropper: no picture to sample Cuentagotas: no hay imagen de la que tomar el color
|
||||
Add/Remove Keyframe Añadir/quitar fotograma clave
|
||||
Temporal Interpolation Interpolación temporal
|
||||
Linear Lineal
|
||||
|
|
@ -1031,6 +1033,7 @@
|
|||
Corner Radius Radio de esquina
|
||||
Polygon Sides Lados del polígono
|
||||
Fill Relleno
|
||||
Gradient Degradado
|
||||
Stroke Trazo
|
||||
Stroke 2 Trazo 2
|
||||
Background Fondo
|
||||
|
|
@ -1891,6 +1894,11 @@
|
|||
Japanese Japonés
|
||||
JetBrains Mono JetBrains Mono
|
||||
Join Through Edits Unir ediciones pasantes
|
||||
Ripple Trim In Recorte de rizo de entrada
|
||||
Ripple Trim Out Recorte de rizo de salida
|
||||
Roll Edit Edición de rodillo
|
||||
Trim In Recortar entrada
|
||||
Trim Out Recortar salida
|
||||
Kaleidoscope Caleidoscopio
|
||||
Keep a recovery copy of unsaved changes Mantener una copia de recuperación de los cambios sin guardar
|
||||
Key Color Color clave
|
||||
|
|
@ -3488,3 +3496,5 @@
|
|||
Light Theme Tema claro
|
||||
Next Appearance Mode Siguiente modo de apariencia
|
||||
Sync with system Sincronizar con el sistema
|
||||
In Entrada
|
||||
Out Salida
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
File diff suppressed because it is too large
Load Diff
|
|
@ -368,7 +368,8 @@ mod tests {
|
|||
if p.is_dir() {
|
||||
dirs.push(p);
|
||||
} else if p.extension().is_some_and(|e| e == "rs") {
|
||||
let text = std::fs::read_to_string(&p).unwrap_or_default();
|
||||
// a Windows checkout with core.autocrlf has CRLF, which the `\n` below would not match
|
||||
let text = std::fs::read_to_string(&p).unwrap_or_default().replace("\r\n", "\n");
|
||||
let cut = text.find("#[cfg(test)]\nmod tests").unwrap_or(text.len());
|
||||
out.push((p.display().to_string(), text[..cut].to_string()));
|
||||
}
|
||||
|
|
@ -474,6 +475,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
crate::panels::timeline::CLIP_MENU.iter().flat_map(|g| g.iter()).for_each(|(l, _)| push(&mut out, l));
|
||||
crate::panels::timeline::EDIT_POINT_TYPES.iter().for_each(|(l, ..)| push(&mut out, l));
|
||||
crate::panels::project::NEW_ITEMS.iter().for_each(|(l, _)| push(&mut out, l));
|
||||
// section headers keyed by their English name (collapsed state), translated when drawn
|
||||
let sections = [
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ pub enum Icon {
|
|||
Monitor,
|
||||
Sun,
|
||||
Moon,
|
||||
/// Colour parameters: pick a colour from the Program monitor (a pipette).
|
||||
Eyedropper,
|
||||
}
|
||||
|
||||
pub struct Pen16<'a> {
|
||||
|
|
@ -754,6 +756,13 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
|||
let pts: Vec<(f32, f32)> = arc(8.0, 8.5, 5.5, 8.56, 261.44).chain(arc(11.2, 5.3, 4.6, 209.12, 60.88).skip(1)).collect();
|
||||
pen.closed(&pts);
|
||||
}
|
||||
Eyedropper => {
|
||||
// a pipette: bulb at the top right, glass tube running to a tip at the bottom left
|
||||
pen.closed(&[(10.0, 4.5), (11.5, 3.0), (13.0, 3.0), (13.0, 4.5), (11.5, 6.0)]);
|
||||
pen.line(&[(8.2, 6.2), (10.8, 8.8)]);
|
||||
pen.line(&[(9.5, 7.5), (3.5, 12.5)]);
|
||||
pen.line(&[(3.5, 12.5), (2.5, 13.5)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -885,6 +894,7 @@ mod tests {
|
|||
Drive,
|
||||
Network,
|
||||
Clock,
|
||||
Eyedropper,
|
||||
];
|
||||
|
||||
/// Paint `icon` at `ppp` device pixels per point into a rect of `size` points whose corner is
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ pub mod panels;
|
|||
pub mod perf;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod play_ahead;
|
||||
pub mod scrub;
|
||||
pub mod source_playback;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
|
|
@ -211,9 +212,10 @@ const SCREENSHOT_TIMEOUT_S: f64 = 10.0;
|
|||
/// timeline zoom animation finished) before capturing what is there.
|
||||
const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0;
|
||||
|
||||
/// File ▸ Export entries that run from the menus through the save panel: command, filter label,
|
||||
/// Export entries that run from the menus through the save panel: command, filter label,
|
||||
/// extension.
|
||||
pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 6] = [
|
||||
pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [
|
||||
("markers.exportCsv", "Marker report (CSV)", "csv"),
|
||||
("file.exportEdl", "EDL", "edl"),
|
||||
("file.exportFcp7Xml", "Final Cut Pro XML", "xml"),
|
||||
("file.exportFcpxml", "FCPXML", "fcpxml"),
|
||||
|
|
@ -230,6 +232,8 @@ pub struct FilmcraftApp {
|
|||
pub playback: Playback,
|
||||
pub source_playback: source_playback::SourcePlayback,
|
||||
pub audio: Option<Box<dyn AudioOut>>,
|
||||
/// Audio during scrubbing (#211).
|
||||
pub scrub: scrub::ScrubAudio,
|
||||
pub hooks: HostHooks,
|
||||
pub dialog: Option<Dialog>,
|
||||
pub file_dialogs: panels::file_dialogs::FileDialogState,
|
||||
|
|
@ -433,6 +437,7 @@ impl FilmcraftApp {
|
|||
playback: Playback { speed: 1.0, ..Default::default() },
|
||||
source_playback: Default::default(),
|
||||
audio: None,
|
||||
scrub: Default::default(),
|
||||
hooks: HostHooks::default(),
|
||||
// Unsaved changes left by a session that died are offered first thing.
|
||||
dialog: recovery.then_some(Dialog::Recovery),
|
||||
|
|
@ -1379,6 +1384,7 @@ impl FilmcraftApp {
|
|||
self.handle_shortcuts(&ctx);
|
||||
self.advance_playback(&ctx);
|
||||
self.advance_source_playback(&ctx);
|
||||
self.scrub_audio(&ctx);
|
||||
let t = self.tokens;
|
||||
let full = ui.max_rect();
|
||||
ui.painter().rect_filled(full, 0.0, t.app_bg);
|
||||
|
|
|
|||
|
|
@ -311,8 +311,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
|
|||
let view = filmcraft_engine::clip_ops::source_view(&app.session, item).ok_or("Source clip is unavailable")?;
|
||||
let count = if id.ends_with('5') { i64::from(app.session.prefs.playback.step_many_frames) } else { 1 };
|
||||
let direction = if id.contains("Back") { -1 } else { 1 };
|
||||
let delta = view.rate.frame_duration().0.saturating_mul(count).saturating_mul(direction);
|
||||
let time = filmcraft_time::Tick(app.session.state.source_playhead.0.saturating_add(delta));
|
||||
let time = if view.rate.frame_duration().0 == 0 {
|
||||
app.session.state.source_playhead
|
||||
} else {
|
||||
let frame = view.rate.frame_at(app.session.state.source_playhead).saturating_add(count.saturating_mul(direction));
|
||||
view.rate.tick_of(frame)
|
||||
};
|
||||
return app.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string());
|
||||
}
|
||||
"playback.forward" => {
|
||||
|
|
@ -700,13 +704,14 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
|||
egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| {
|
||||
for top in MENUS {
|
||||
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
|
||||
ui.menu_button(app.ui.language.tr(top), |ui| {
|
||||
let r = ui.menu_button(app.ui.language.tr(top), |ui| {
|
||||
ui.set_min_width(260.0);
|
||||
if mine.is_empty() {
|
||||
ui.add_enabled(false, egui::Button::new(tl!("(empty)")));
|
||||
}
|
||||
menu_level(ui, &mine, 1, &mut clicked);
|
||||
menu_level(ui, &mine, 1, &mut clicked, &mut app.auto);
|
||||
});
|
||||
app.auto.add(&format!("menu.{top}"), r.response.rect, top);
|
||||
}
|
||||
});
|
||||
if let Some(id) = clicked {
|
||||
|
|
@ -716,7 +721,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
|||
|
||||
/// One menu level: items whose path ends here, and a submenu (at its first item's position) for
|
||||
/// each deeper path segment, recursively (e.g. Clip ▸ Video Options ▸ Time Interpolation).
|
||||
fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
|
||||
fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, auto: &mut crate::automation::Registry) {
|
||||
let mut subs: Vec<&str> = Vec::new();
|
||||
for it in items {
|
||||
if let Some(sub) = it.path.get(depth).map(String::as_str) {
|
||||
|
|
@ -728,16 +733,16 @@ fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mu
|
|||
let language = ui.ctx().data(|d| d.get_temp::<crate::i18n::Language>(egui::Id::new("interface-language"))).unwrap_or_default();
|
||||
ui.menu_button(language.tr(sub), |ui| {
|
||||
ui.set_min_width(220.0);
|
||||
menu_level(ui, &inner, depth + 1, clicked);
|
||||
menu_level(ui, &inner, depth + 1, clicked, auto);
|
||||
});
|
||||
} else if menu_entry(ui, it) {
|
||||
} else if menu_entry(ui, it, auto) {
|
||||
*clicked = Some(it.id.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool {
|
||||
fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry) -> bool {
|
||||
// checkable items leave room for a checkmark drawn at the left
|
||||
let label = if it.checked.is_some() { format!(" {}", it.label) } else { it.label.clone() };
|
||||
let mut b = egui::Button::new(label);
|
||||
|
|
@ -745,6 +750,7 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool {
|
|||
b = b.shortcut_text(shortcut_text(s));
|
||||
}
|
||||
let r = ui.add_enabled(it.enabled, b);
|
||||
auto.add(&format!("menu.{}", it.id), r.rect, &it.label);
|
||||
if it.checked == Some(true) {
|
||||
let c = r.rect.left_center() + egui::vec2(10.0, 0.0);
|
||||
let col = ui.visuals().text_color();
|
||||
|
|
|
|||
|
|
@ -253,10 +253,10 @@ fn response_plot(ui: &mut egui::Ui, ed: &mut Ed, size: egui::Vec2, range: f64) -
|
|||
let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());
|
||||
let p = ui.painter_at(r);
|
||||
let t = &ed.t;
|
||||
p.rect_filled(r, 2.0, Color32::from_gray(0x18));
|
||||
p.rect_filled(r, 2.0, ed.t.plot_bg);
|
||||
for f in [50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0] {
|
||||
let x = x_of(f, r);
|
||||
p.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_gray(0x2c)));
|
||||
p.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, ed.t.plot_grid));
|
||||
let label = if f >= 1000.0 { format!("{}k", f / 1000.0) } else { format!("{f}") };
|
||||
p.text(pos2(x + 2.0, r.max.y - 2.0), Align2::LEFT_BOTTOM, label, Tokens::ui(9.0), t.text_faint);
|
||||
}
|
||||
|
|
@ -264,7 +264,7 @@ fn response_plot(ui: &mut egui::Ui, ed: &mut Ed, size: egui::Vec2, range: f64) -
|
|||
let mut d = -range;
|
||||
while d <= range + 1e-9 {
|
||||
let y = y_of(d, r, range);
|
||||
let col = if d.abs() < 1e-9 { Color32::from_gray(0x44) } else { Color32::from_gray(0x2c) };
|
||||
let col = if d.abs() < 1e-9 { ed.t.plot_axis } else { ed.t.plot_grid };
|
||||
p.line_segment([pos2(r.min.x, y), pos2(r.max.x, y)], Stroke::new(1.0, col));
|
||||
p.text(pos2(r.min.x + 2.0, y - 1.0), Align2::LEFT_BOTTOM, format!("{d:+.0}"), Tokens::ui(9.0), t.text_faint);
|
||||
d += step;
|
||||
|
|
@ -310,7 +310,7 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) {
|
|||
let id = egui::Id::new(("peq-node", ed.target.clone(), pre));
|
||||
let resp = ui.interact(nr, id, Sense::click_and_drag());
|
||||
let on = ed.on(&on_id);
|
||||
let col = if on { Color32::from_rgb(0xe8, 0xe8, 0xe8) } else { Color32::from_gray(0x70) };
|
||||
let col = if on { ed.t.eq_node } else { ed.t.eq_node_off };
|
||||
ui.painter().circle_stroke(c, 7.0, Stroke::new(1.5, col));
|
||||
ui.painter().text(c, Align2::CENTER_CENTER, label, Tokens::ui(8.5), col);
|
||||
ed.auto(format!("fxEditor.{}.node.{pre}", ed.fx), nr, label);
|
||||
|
|
@ -374,14 +374,14 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) {
|
|||
let pid = format!("b{}", i + 1);
|
||||
let x = area.min.x + colw * (i as f32 + 0.5);
|
||||
let track = Rect::from_center_size(pos2(x, area.min.y + 70.0), vec2(6.0, 130.0));
|
||||
ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x30));
|
||||
ui.painter().line_segment([pos2(x - 5.0, track.center().y), pos2(x + 5.0, track.center().y)], Stroke::new(1.0, Color32::from_gray(0x60)));
|
||||
ui.painter().rect_filled(track, 2.0, ed.t.eq_track);
|
||||
ui.painter().line_segment([pos2(x - 5.0, track.center().y), pos2(x + 5.0, track.center().y)], Stroke::new(1.0, ed.t.eq_tick));
|
||||
let v = ed.v(&pid);
|
||||
let ky = track.center().y - (v / 24.0) as f32 * track.height() * 0.5;
|
||||
let knob = Rect::from_center_size(pos2(x, ky), vec2(colw.min(18.0), 8.0));
|
||||
let id = egui::Id::new(("geq-band", ed.target.clone(), i));
|
||||
let resp = ui.interact(knob.union(track).expand(3.0), id, Sense::click_and_drag());
|
||||
ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { Color32::from_gray(0xf0) } else { Color32::from_gray(0xc0) });
|
||||
ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { ed.t.eq_knob_active } else { ed.t.eq_knob });
|
||||
ui.painter().text(pos2(x, area.max.y - 22.0), Align2::CENTER_CENTER, format!("{v:+.0}"), Tokens::ui(8.5), ed.t.text_dim);
|
||||
let short = label.replace(" Hz", "").replace(" kHz", "k");
|
||||
ui.painter().text(pos2(x, area.max.y - 8.0), Align2::CENTER_CENTER, short, Tokens::ui(8.5), ed.t.text_faint);
|
||||
|
|
@ -420,14 +420,14 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) {
|
|||
fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: String) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
|
||||
let p = ui.painter_at(r);
|
||||
p.rect_filled(r, 2.0, Color32::from_gray(0x18));
|
||||
p.rect_filled(r, 2.0, ed.t.plot_bg);
|
||||
let lo = -60.0;
|
||||
let map = |x: f64, y: f64| pos2(r.min.x + ((x - lo) / -lo) as f32 * r.width(), r.max.y - (((y - lo) / -lo).clamp(-0.05, 1.05)) as f32 * r.height());
|
||||
for d in [-48.0, -36.0, -24.0, -12.0] {
|
||||
p.line_segment([map(d, lo), map(d, 0.0)], Stroke::new(1.0, Color32::from_gray(0x2c)));
|
||||
p.line_segment([map(lo, d), map(0.0, d)], Stroke::new(1.0, Color32::from_gray(0x2c)));
|
||||
p.line_segment([map(d, lo), map(d, 0.0)], Stroke::new(1.0, ed.t.plot_grid));
|
||||
p.line_segment([map(lo, d), map(0.0, d)], Stroke::new(1.0, ed.t.plot_grid));
|
||||
}
|
||||
p.line_segment([map(lo, lo), map(0.0, 0.0)], Stroke::new(1.0, Color32::from_gray(0x44)));
|
||||
p.line_segment([map(lo, lo), map(0.0, 0.0)], Stroke::new(1.0, ed.t.plot_axis));
|
||||
if let Some(dsp) = filmcraft_render::audio_fx::configured(&ed.preview(), ed.mt, 48000) {
|
||||
let pts: Vec<Pos2> = (0..=120)
|
||||
.filter_map(|i| {
|
||||
|
|
@ -447,7 +447,7 @@ fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: S
|
|||
fn multiband(ui: &mut egui::Ui, ed: &mut Ed) {
|
||||
// Spectrum strip with draggable crossover handles.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(640.0, 70.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, 2.0, Color32::from_gray(0x18));
|
||||
ui.painter().rect_filled(r, 2.0, ed.t.plot_bg);
|
||||
let cols =
|
||||
[Color32::from_rgb(0x3d, 0x6e, 0xb4), Color32::from_rgb(0x3d, 0xa0, 0x6e), Color32::from_rgb(0xb4, 0x9a, 0x3d), Color32::from_rgb(0xb4, 0x4f, 0x3d)];
|
||||
let xo = [ed.v("xo1"), ed.v("xo2"), ed.v("xo3")];
|
||||
|
|
@ -461,7 +461,8 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) {
|
|||
let x = edges[k + 1];
|
||||
let hr = Rect::from_center_size(pos2(x, r.center().y), vec2(10.0, r.height()));
|
||||
let resp = ui.interact(hr, egui::Id::new(("mb-xo", ed.target.clone(), k)), Sense::drag());
|
||||
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, Color32::WHITE));
|
||||
ui.painter()
|
||||
.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, ed.t.crossover_handle));
|
||||
ui.painter().text(pos2(x + 3.0, r.min.y + 2.0), Align2::LEFT_TOP, format!("{:.0} Hz", xo[k]), Tokens::ui(9.0), ed.t.text_dim);
|
||||
ed.auto(format!("fxEditor.{}.{pid}", ed.fx), hr, "Crossover");
|
||||
if resp.dragged()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! keyframe lane on the right. Also hosts the Lumetri Color panel body (same editor, grouped).
|
||||
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use filmcraft_project::{ClipId, EffectInstance, ParamKind, ParamValue, TrackItem};
|
||||
use filmcraft_project::{ClipId, EffectInstance, ParamKind, ParamValue, TrackItem, TrackKind};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -28,24 +28,37 @@ fn timeline_time_of(it: &TrackItem, m: Tick) -> Tick {
|
|||
it.start + Tick(((m - it.source_in).0 as f64 / it.speed.abs().max(1e-6)) as i64)
|
||||
}
|
||||
|
||||
fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, filmcraft_project::TrackKind)> {
|
||||
let seq = app.session.active_sequence()?;
|
||||
let mut best = None;
|
||||
for c in &app.session.state.selection {
|
||||
if let Some((tid, it)) = seq.find_item(*c) {
|
||||
let kind = seq.track(tid)?.kind;
|
||||
if kind == filmcraft_project::TrackKind::Video {
|
||||
return Some((*c, it.clone(), kind));
|
||||
}
|
||||
best.get_or_insert((*c, it.clone(), kind));
|
||||
}
|
||||
fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> {
|
||||
selected_clips(app).into_iter().next()
|
||||
}
|
||||
|
||||
/// The selected clip (video first) and, for a video clip, the selected audio linked to it.
|
||||
fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> {
|
||||
let Some(seq) = app.session.active_sequence() else { return Vec::new() };
|
||||
let picked: Vec<(ClipId, &TrackItem, TrackKind)> = app
|
||||
.session
|
||||
.state
|
||||
.selection
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let (tid, it) = seq.find_item(*c)?;
|
||||
Some((*c, it, seq.track(tid)?.kind))
|
||||
})
|
||||
.collect();
|
||||
let Some(&(clip, it, kind)) = picked.iter().find(|p| p.2 == TrackKind::Video).or(picked.first()) else { return Vec::new() };
|
||||
let mut out = vec![(clip, it.clone(), kind)];
|
||||
if kind == TrackKind::Video
|
||||
&& let Some(&(ac, ai, ak)) = picked.iter().find(|p| p.2 == TrackKind::Audio && p.1.link.is_some() && p.1.link == it.link)
|
||||
{
|
||||
out.push((ac, ai.clone(), ak));
|
||||
}
|
||||
best
|
||||
out
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let Some((clip, it, kind)) = selected_clip(app) else {
|
||||
let clips = selected_clips(app);
|
||||
let Some((clip, it, _)) = clips.first().cloned() else {
|
||||
crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)"));
|
||||
return;
|
||||
};
|
||||
|
|
@ -57,7 +70,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let head = Rect::from_min_size(rect.min + vec2(8.0, 4.0), vec2(split - rect.min.x - 12.0, 24.0));
|
||||
// Premiere: two pill tabs — "Source · clip" and "Sequence · clip" (active)
|
||||
let pill = |ui: &mut egui::Ui, r: Rect, text: &str, active: bool| {
|
||||
ui.painter().rect_filled(r, 4.0, if active { Color32::from_rgb(0x3a, 0x3a, 0x3a) } else { t.panel_bg });
|
||||
ui.painter().rect_filled(r, 4.0, if active { t.pill_active_bg } else { t.panel_bg });
|
||||
if !active {
|
||||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||||
}
|
||||
|
|
@ -78,7 +91,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H));
|
||||
paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t);
|
||||
let bar = Rect::from_min_max(pos2(lane.min.x, ruler.max.y + 4.0), pos2(lane.max.x, ruler.max.y + 4.0 + CLIP_BAR_H));
|
||||
ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, Color32::from_rgb(58, 58, 70));
|
||||
ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg);
|
||||
ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text);
|
||||
// click or drag anywhere on the ruler (or the bar) to move the playhead
|
||||
let scrub = Rect::from_min_max(ruler.min, bar.max);
|
||||
|
|
@ -97,90 +110,93 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("ec-body"));
|
||||
bui.set_clip_rect(Rect::from_min_max(body.min, pos2(rect.max.x, body.max.y)));
|
||||
let mt_now = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let heading = if kind == filmcraft_project::TrackKind::Video { tl!("Video") } else { tl!("Audio") };
|
||||
bui.painter().text(pos2(body.min.x + 8.0, body.min.y + 8.0), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim);
|
||||
bui.add_space(18.0);
|
||||
// Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first.
|
||||
let mut order: Vec<usize> = (0..it.effects.len()).collect();
|
||||
order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic));
|
||||
let scroll_out = egui::ScrollArea::vertical().id_salt("ec-scroll").auto_shrink([false, false]).show(&mut bui, |bui| {
|
||||
for idx in order {
|
||||
let e = &it.effects[idx];
|
||||
let Some(def) = e.def() else { continue };
|
||||
let key = format!("{}:{}", clip.0, idx);
|
||||
let open = !app.ui.collapsed_fx.contains(&key);
|
||||
let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click());
|
||||
if resp.hovered() {
|
||||
bui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(
|
||||
bui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)),
|
||||
if open { Icon::ChevronDown } else { Icon::ChevronRight },
|
||||
t.text_dim,
|
||||
);
|
||||
// fx enable toggle
|
||||
let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0));
|
||||
let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click());
|
||||
bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint });
|
||||
if !e.enabled {
|
||||
bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint));
|
||||
}
|
||||
if fxresp.clicked() {
|
||||
actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx})));
|
||||
}
|
||||
bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text);
|
||||
// reset button
|
||||
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect"));
|
||||
icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim });
|
||||
if rresp.clicked() {
|
||||
actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name);
|
||||
if resp.clicked() && !fxresp.clicked() && !rresp.clicked() {
|
||||
if open {
|
||||
app.ui.collapsed_fx.push(key.clone());
|
||||
} else {
|
||||
app.ui.collapsed_fx.retain(|k| *k != key);
|
||||
for (clip, it, kind) in &clips {
|
||||
let clip = *clip;
|
||||
let (hr, _) = bui.allocate_exact_size(vec2(body.width(), 18.0), Sense::hover());
|
||||
let heading = if *kind == TrackKind::Video { tl!("Video") } else { tl!("Audio") };
|
||||
bui.painter().text(pos2(hr.min.x + 8.0, hr.center().y), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim);
|
||||
let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
// Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first.
|
||||
let mut order: Vec<usize> = (0..it.effects.len()).collect();
|
||||
order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic));
|
||||
for idx in order {
|
||||
let e = &it.effects[idx];
|
||||
let Some(def) = e.def() else { continue };
|
||||
let key = format!("{}:{}", clip.0, idx);
|
||||
let open = !app.ui.collapsed_fx.contains(&key);
|
||||
let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click());
|
||||
if resp.hovered() {
|
||||
bui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
}
|
||||
let mut save_preset = false;
|
||||
let fx_id = e.effect.clone();
|
||||
resp.context_menu(|ui| {
|
||||
let sp = ui.button(tl!("Save Preset…"));
|
||||
app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…");
|
||||
if sp.clicked() {
|
||||
save_preset = true;
|
||||
ui.close();
|
||||
icons::paint(
|
||||
bui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)),
|
||||
if open { Icon::ChevronDown } else { Icon::ChevronRight },
|
||||
t.text_dim,
|
||||
);
|
||||
// fx enable toggle
|
||||
let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0));
|
||||
let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click());
|
||||
bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint });
|
||||
if !e.enabled {
|
||||
bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint));
|
||||
}
|
||||
if !def.intrinsic && ui.button(tl!("Clear")).clicked() {
|
||||
actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx})));
|
||||
ui.close();
|
||||
if fxresp.clicked() {
|
||||
actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx})));
|
||||
}
|
||||
});
|
||||
if save_preset {
|
||||
crate::panels::presets::open_save(app, clip.0, vec![idx], def.name);
|
||||
}
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
if crate::panels::audio_fx_editor::has_editor(&e.effect) {
|
||||
custom_setup_row(app, bui, body, clip, idx, &e.effect);
|
||||
}
|
||||
for pd in &def.params {
|
||||
param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, &it);
|
||||
if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id))
|
||||
&& let Some(param) = e.params.get(pd.id)
|
||||
&& param.is_animated()
|
||||
&& matches!(param.value, ParamValue::Float(_))
|
||||
{
|
||||
graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, &it, &mut actions);
|
||||
bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text);
|
||||
// reset button
|
||||
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect"));
|
||||
icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim });
|
||||
if rresp.clicked() {
|
||||
actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name);
|
||||
if resp.clicked() && !fxresp.clicked() && !rresp.clicked() {
|
||||
if open {
|
||||
app.ui.collapsed_fx.push(key.clone());
|
||||
} else {
|
||||
app.ui.collapsed_fx.retain(|k| *k != key);
|
||||
}
|
||||
}
|
||||
let mut save_preset = false;
|
||||
let fx_id = e.effect.clone();
|
||||
resp.context_menu(|ui| {
|
||||
let sp = ui.button(tl!("Save Preset…"));
|
||||
app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…");
|
||||
if sp.clicked() {
|
||||
save_preset = true;
|
||||
ui.close();
|
||||
}
|
||||
if !def.intrinsic && ui.button(tl!("Clear")).clicked() {
|
||||
actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx})));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if save_preset {
|
||||
crate::panels::presets::open_save(app, clip.0, vec![idx], def.name);
|
||||
}
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
if crate::panels::audio_fx_editor::has_editor(&e.effect) {
|
||||
custom_setup_row(app, bui, body, clip, idx, &e.effect);
|
||||
}
|
||||
for pd in &def.params {
|
||||
param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, it);
|
||||
if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id))
|
||||
&& let Some(param) = e.params.get(pd.id)
|
||||
&& param.is_animated()
|
||||
&& matches!(param.value, ParamValue::Float(_))
|
||||
{
|
||||
graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, it, &mut actions);
|
||||
}
|
||||
}
|
||||
if crate::panels::masks::maskable(e) {
|
||||
crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, it);
|
||||
}
|
||||
}
|
||||
if crate::panels::masks::maskable(e) {
|
||||
crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, &it);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -466,6 +482,14 @@ pub(crate) fn param_row(
|
|||
if egui::color_picker::color_edit_button_rgba(&mut vui, &mut rgba, egui::color_picker::Alpha::Opaque).changed() {
|
||||
set = Some(json!([rgba.r(), rgba.g(), rgba.b(), rgba.a()]));
|
||||
}
|
||||
// Eyedropper: arm it, then click a pixel in the Program monitor (Esc cancels)
|
||||
let target = crate::state::Eyedropper { clip: clip.0, effect: idx, param: pd.id.to_string(), mask };
|
||||
let armed = app.ui.eyedropper.as_ref() == Some(&target);
|
||||
let drop = icons::button(&mut vui, Icon::Eyedropper, 18.0, armed, &t, tl!("Eyedropper (pick a color from the Program monitor)"));
|
||||
app.auto.add(&format!("effectControls.{}.{}.eyedropper", e.effect, pkey), drop.rect, "Eyedropper");
|
||||
if drop.clicked() {
|
||||
app.ui.eyedropper = if armed { None } else { Some(target) };
|
||||
}
|
||||
}
|
||||
(ParamKind::Path, _) => {
|
||||
crate::panels::masks::path_value(app, &mut vui, clip, idx, mask, actions);
|
||||
|
|
@ -659,7 +683,8 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
/// Properties panel (Premiere 26): a compact inspector for the selected clip.
|
||||
pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let Some((clip, it, kind)) = selected_clip(app) else {
|
||||
let clips = selected_clips(app);
|
||||
let Some((clip, it, kind)) = clips.first().cloned() else {
|
||||
crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to see its properties"));
|
||||
return;
|
||||
};
|
||||
|
|
@ -709,7 +734,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
};
|
||||
let eff_idx = |id: &str| it.effects.iter().position(|e| e.effect == id);
|
||||
let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt));
|
||||
if kind == filmcraft_project::TrackKind::Video {
|
||||
if kind == TrackKind::Video {
|
||||
if section(&mut bui, app, "Transform", eff_idx("motion").map(|i| (i, clip.0)), &mut actions) {
|
||||
for (label, p, unit, speed, range) in [
|
||||
(tl!("Position"), "position", "", 1.0, (-100_000.0, 100_000.0)),
|
||||
|
|
@ -780,7 +805,13 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
properties_nav(app, &mut bui, r, clip, &it, mt, "crop", p, &mut actions);
|
||||
}
|
||||
}
|
||||
} else if section(&mut bui, app, "Audio", eff_idx("volume").map(|i| (i, clip.0)), &mut actions) {
|
||||
}
|
||||
if let Some((clip, it, _)) = clips.iter().find(|c| c.2 == TrackKind::Audio)
|
||||
&& section(&mut bui, app, "Audio", it.effects.iter().position(|e| e.effect == "volume").map(|i| (i, clip.0)), &mut actions)
|
||||
{
|
||||
let clip = *clip;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt));
|
||||
let r = row(&mut bui, tl!("Level"));
|
||||
let mut vui = bui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
|
|
@ -792,7 +823,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if let Some(nv) = nv {
|
||||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv})));
|
||||
}
|
||||
properties_nav(app, &mut bui, r, clip, &it, mt, "volume", "level", &mut actions);
|
||||
properties_nav(app, &mut bui, r, clip, it, mt, "volume", "level", &mut actions);
|
||||
let r = row(&mut bui, tl!("Pan"));
|
||||
let mut vui = bui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
|
|
@ -892,10 +923,10 @@ pub(crate) fn graph_rows(
|
|||
let y_of = |v: f64| area.max.y - ((v - lo) / (hi - lo)) as f32 * area.height();
|
||||
let v_of = |y: f32| lo + ((area.max.y - y) / area.height()) as f64 * (hi - lo);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(area, 0.0, Color32::from_rgb(0x19, 0x19, 0x19));
|
||||
p.rect_filled(area, 0.0, t.keyframe_plot_bg);
|
||||
for g in 1..4 {
|
||||
let y = area.min.y + area.height() * g as f32 / 4.0;
|
||||
p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, Color32::from_rgb(0x2a, 0x2a, 0x2a)));
|
||||
p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, t.keyframe_plot_grid));
|
||||
}
|
||||
// range labels in the property column
|
||||
let dec = if let ParamKind::Float { decimals, .. } = pd.kind { decimals as usize } else { 1 };
|
||||
|
|
@ -909,9 +940,9 @@ pub(crate) fn graph_rows(
|
|||
let vel: Vec<f64> = (0..n).map(|i| (vals[i + 1] - vals[i]) / secs.max(1e-9)).collect();
|
||||
let vmax = vel.iter().fold(1e-6f64, |a, v| a.max(v.abs())) * 1.15;
|
||||
let varea = Rect::from_min_max(pos2(lane.min.x, velr.min.y + 2.0), pos2(lane.max.x, velr.max.y - 4.0));
|
||||
p.rect_filled(varea, 0.0, Color32::from_rgb(0x19, 0x19, 0x19));
|
||||
p.rect_filled(varea, 0.0, t.keyframe_plot_bg);
|
||||
let vy = |v: f64| varea.center().y - (v / vmax) as f32 * varea.height() / 2.0;
|
||||
p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, Color32::from_rgb(0x33, 0x33, 0x33)));
|
||||
p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, t.keyframe_plot_axis));
|
||||
let vline: Vec<Pos2> = vel.iter().enumerate().map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect();
|
||||
p.add(egui::Shape::line(vline, Stroke::new(1.2, Color32::from_rgb(0xd0, 0xa0, 0x40))));
|
||||
p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, tl!("Velocity"), Tokens::ui(11.0), t.text_dim);
|
||||
|
|
@ -944,8 +975,8 @@ pub(crate) fn graph_rows(
|
|||
let hdx: f32 = ui.data(|d| d.get_temp(hid)).unwrap_or(0.0);
|
||||
let hx = c.x + side * (infl * seg + hdx * side).clamp(seg * 0.01, seg);
|
||||
let hp = pos2(hx, c.y);
|
||||
p.line_segment([c, hp], Stroke::new(1.0, Color32::from_rgb(0x90, 0x90, 0x90)));
|
||||
p.circle_filled(hp, 3.5, Color32::from_rgb(0xd0, 0xd0, 0xd0));
|
||||
p.line_segment([c, hp], Stroke::new(1.0, t.plot_handle_dim));
|
||||
p.circle_filled(hp, 3.5, t.keyframe_handle);
|
||||
let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag());
|
||||
if hr.dragged() {
|
||||
let nx = hdx + hr.drag_delta().x;
|
||||
|
|
@ -958,7 +989,7 @@ pub(crate) fn graph_rows(
|
|||
actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni}))));
|
||||
}
|
||||
}
|
||||
p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { Color32::from_rgb(0xe0, 0xe0, 0xe0) });
|
||||
p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle });
|
||||
if resp.dragged() {
|
||||
let ny = dy + resp.drag_delta().y;
|
||||
ui.data_mut(|d| d.insert_temp(id, ny));
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
for (on, label) in [(d.yuv, "YUV"), (d.float32, "32"), (d.accelerated, "⚡")] {
|
||||
if on {
|
||||
let br = Rect::from_min_size(pos2(bx - 22.0, r.min.y + 3.0), vec2(20.0, 14.0));
|
||||
ui.painter().rect_filled(br, 2.0, Color32::from_rgb(48, 48, 48));
|
||||
ui.painter().rect_filled(br, 2.0, t.field_border);
|
||||
ui.painter().text(br.center(), Align2::CENTER_CENTER, label, Tokens::ui(8.5), t.text_dim);
|
||||
bx -= 24.0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) {
|
|||
br,
|
||||
4.0,
|
||||
if sel {
|
||||
Color32::from_gray(0x4b)
|
||||
cx.t.pressed
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
|
|
@ -387,7 +387,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) {
|
|||
br,
|
||||
4.0,
|
||||
if sel {
|
||||
Color32::from_gray(0x4b)
|
||||
cx.t.pressed
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
|
|
@ -467,11 +467,11 @@ fn switch(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, on: bool, id: &str, label: &
|
|||
let p = ui.painter();
|
||||
let rad = r.height() / 2.0;
|
||||
if on {
|
||||
p.rect_filled(r, rad, Color32::from_gray(0xd4));
|
||||
p.circle_filled(pos2(r.max.x - rad, r.center().y), rad - 3.0, Color32::from_gray(0x1d));
|
||||
p.rect_filled(r, rad, cx.t.switch_fill);
|
||||
p.circle_filled(pos2(r.max.x - rad, r.center().y), rad - 3.0, cx.t.switch_knob);
|
||||
} else {
|
||||
p.rect_stroke(r, rad, Stroke::new(1.5, Color32::from_gray(if resp.hovered() { 0xb0 } else { 0x8a })), StrokeKind::Inside);
|
||||
p.circle_stroke(pos2(r.min.x + rad, r.center().y), rad - 3.0, Stroke::new(1.5, Color32::from_gray(0xd4)));
|
||||
p.rect_stroke(r, rad, Stroke::new(1.5, if resp.hovered() { cx.t.control_handle_dim } else { cx.t.switch_border }), StrokeKind::Inside);
|
||||
p.circle_stroke(pos2(r.min.x + rad, r.center().y), rad - 3.0, Stroke::new(1.5, cx.t.switch_fill));
|
||||
}
|
||||
cx.auto.add(id, r, label);
|
||||
resp.clicked()
|
||||
|
|
@ -521,7 +521,7 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64,
|
|||
let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0) as f32;
|
||||
let kx = track.min.x + f * track.width();
|
||||
let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::new(("es-slider", key)), Sense::click_and_drag());
|
||||
let line = if active { Color32::from_gray(0x8a) } else { Color32::from_gray(0x4a) };
|
||||
let line = if active { cx.t.switch_border } else { cx.t.slider_disabled_track };
|
||||
ui.painter().line_segment([pos2(track.min.x, y), pos2(kx - 7.0, y)], Stroke::new(1.5, line));
|
||||
ui.painter().line_segment([pos2(kx + 7.0, y), pos2(track.max.x, y)], Stroke::new(1.5, line));
|
||||
ui.painter().circle_filled(pos2(kx, y), 6.0, t.panel_bg);
|
||||
|
|
@ -533,9 +533,9 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64,
|
|||
if resp.dragged() {
|
||||
t.hot_text
|
||||
} else if active {
|
||||
Color32::from_gray(0xd4)
|
||||
cx.t.switch_fill
|
||||
} else {
|
||||
Color32::from_gray(0x6a)
|
||||
cx.t.slider_disabled_knob
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -587,7 +587,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us
|
|||
br,
|
||||
4.0,
|
||||
if on {
|
||||
Color32::from_gray(0x4b)
|
||||
cx.t.pressed
|
||||
} else if resp.hovered() && active {
|
||||
t.hover
|
||||
} else {
|
||||
|
|
@ -605,8 +605,8 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us
|
|||
fn button(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, text: &str, id: &str) -> bool {
|
||||
let t = cx.t;
|
||||
let resp = ui.interact(r, egui::Id::new(("es-btn", id)), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { Color32::from_gray(0x2a) } else { t.field_bg });
|
||||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, Color32::from_gray(0x4b)), StrokeKind::Inside);
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { cx.t.button_hover } else { t.field_bg });
|
||||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, cx.t.button_border), StrokeKind::Inside);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
cx.auto.add(id, r, text);
|
||||
resp.clicked()
|
||||
|
|
|
|||
|
|
@ -1536,8 +1536,22 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
// ---- appearance
|
||||
if section(ui, app, "Appearance", &t) {
|
||||
let (_, mut vui) = cx.row(ui, tl!("Fill"));
|
||||
cx.check(&mut vui, "fill", "");
|
||||
let fill_on = cx.check(&mut vui, "fill", "");
|
||||
cx.color(&mut vui, "fill_color");
|
||||
if fill_on {
|
||||
vui.add_space(6.0);
|
||||
cx.choice(&mut vui, "fill_kind", graphic::FILL_KIND_OPTS, 130.0);
|
||||
}
|
||||
let fill_kind = match pv(e, "fill_kind", mt) {
|
||||
ParamValue::Choice(c) => c,
|
||||
_ => 0,
|
||||
};
|
||||
if fill_on && fill_kind == 1 {
|
||||
let (_, mut vui) = cx.row(ui, tl!("Gradient"));
|
||||
cx.color(&mut vui, "gradient_start");
|
||||
cx.color(&mut vui, "gradient_end");
|
||||
cx.number(ui, tl!(" Angle"), "gradient_angle", 0.5, (-3600.0, 3600.0), 0, " °");
|
||||
}
|
||||
for (on, col, w, kind, label) in [
|
||||
("stroke", "stroke_color", "stroke_width", "stroke_type", tl!("Stroke")),
|
||||
("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", tl!("Stroke 2")),
|
||||
|
|
|
|||
|
|
@ -478,7 +478,8 @@ fn text_panel(app: &mut FilmcraftApp, op: &str) -> Result<Value, String> {
|
|||
let n = words.len();
|
||||
let paras = filmcraft_edit::transcript::paragraphs(&words, filmcraft_time::Tick::from_seconds_f64(1.5));
|
||||
let para_of = |i: usize| paras.iter().position(|p| p.contains(&i)).unwrap_or(0);
|
||||
let (anchor, cur) = app.ui.transcript_sel.unwrap_or_else(|| {
|
||||
// a selection left over from a longer transcript (e.g. after `transcript.extract` over the control channel) counts as none, as when drawing
|
||||
let (anchor, cur) = app.ui.transcript_sel.filter(|(a, b)| *a < n && *b < n).unwrap_or_else(|| {
|
||||
let i = filmcraft_edit::transcript::word_at(&words, app.session.playhead()).unwrap_or(0);
|
||||
(i, i)
|
||||
});
|
||||
|
|
@ -513,3 +514,32 @@ fn text_panel(app: &mut FilmcraftApp, op: &str) -> Result<Value, String> {
|
|||
app.session.set_playhead(words[to].start);
|
||||
Ok(json!({"selection": [anchor.min(to), anchor.max(to)], "word": to}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod transcript_selection_tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn prev_word_after_the_transcript_shrank_under_the_selection_does_not_panic() {
|
||||
let mut session = filmcraft_engine::Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let a = session.active_sequence().unwrap().audio_tracks[0].items[0].clone();
|
||||
let tk = |s: f64| a.source_in.0 + (s * filmcraft_time::TICKS_PER_SECOND as f64) as i64;
|
||||
let words: Vec<Value> = ["one", "two", "three", "four"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| json!({"text": w, "start": tk(0.5 + i as f64 * 0.5), "end": tk(0.9 + i as f64 * 0.5)}))
|
||||
.collect();
|
||||
session.execute("transcript.set", json!({"item": a.item.0, "transcript": {"language": "en", "words": words}})).unwrap();
|
||||
let mut app = crate::FilmcraftApp::new(session);
|
||||
app.ui.text_tab = "Transcript".into();
|
||||
app.ui.transcript_sel = Some((3, 3));
|
||||
// the transcript shrinks behind the panel's back, as with `transcript.extract` over the control channel
|
||||
app.session.execute("transcript.extract", json!({"from": 0, "to": 1})).unwrap();
|
||||
assert_eq!(filmcraft_engine::transcript::sequence_words(&app.session).len(), 2);
|
||||
let r = super::text_panel(&mut app, "prevWord").unwrap();
|
||||
let (sa, sb) = app.ui.transcript_sel.unwrap();
|
||||
assert!(sa < 2 && sb < 2, "selection {sa}..{sb} outside the 2-word transcript");
|
||||
assert_eq!(r["word"], json!(sb));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
let accent = app.tokens.accent;
|
||||
let sel_path = d.candidate.and_then(|c| d.candidates.get(c)).map(|c| c.0.clone());
|
||||
let preview = sel_path.as_deref().and_then(|p| preview_texture(app, ctx, p));
|
||||
let rows_max_h = (ctx.content_rect().height() * 0.3).clamp(90.0, 320.0);
|
||||
egui::Window::new(tl!("Link Media"))
|
||||
.id(egui::Id::new("Link Media"))
|
||||
.collapsible(false)
|
||||
|
|
@ -170,24 +171,26 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
.show(ctx, |ui| {
|
||||
ui.label(tlf!("{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", n = rows.len()));
|
||||
ui.add_space(6.0);
|
||||
egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| {
|
||||
for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for (k, (_, name, fname, path, status)) in rows.iter().enumerate() {
|
||||
let r = ui.selectable_label(d.row == k, name);
|
||||
push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone());
|
||||
if r.clicked() && d.row != k {
|
||||
d.row = k;
|
||||
d.candidates.clear();
|
||||
d.candidate = None;
|
||||
egui::ScrollArea::vertical().id_salt("link-media-rows-scroll").max_height(rows_max_h).auto_shrink([false, true]).show(ui, |ui| {
|
||||
egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| {
|
||||
for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.label(fname);
|
||||
ui.label(RichText::new(path).small());
|
||||
ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a)));
|
||||
ui.end_row();
|
||||
}
|
||||
for (k, (_, name, fname, path, status)) in rows.iter().enumerate() {
|
||||
let r = ui.selectable_label(d.row == k, name);
|
||||
push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone());
|
||||
if r.clicked() && d.row != k {
|
||||
d.row = k;
|
||||
d.candidates.clear();
|
||||
d.candidate = None;
|
||||
}
|
||||
ui.label(fname);
|
||||
ui.label(RichText::new(path).small());
|
||||
ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -234,24 +237,26 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
if d.candidates.is_empty() {
|
||||
ui.label(RichText::new(tl!("No candidates yet — Search a folder or Locate the file.")).weak());
|
||||
}
|
||||
for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() {
|
||||
let mark = match (ok, idm) {
|
||||
(true, Some(true)) => tl!("✔ same file"),
|
||||
(true, _) => tl!("✔ matches"),
|
||||
(false, Some(false)) => tl!("✖ different file"),
|
||||
_ => "✖",
|
||||
};
|
||||
let text = format!("{mark} {path}");
|
||||
let r = ui.selectable_label(
|
||||
d.candidate == Some(k),
|
||||
RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }),
|
||||
);
|
||||
let r = if problems.is_empty() { r } else { r.on_hover_text(problems) };
|
||||
push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text);
|
||||
if r.clicked() {
|
||||
d.candidate = Some(k);
|
||||
egui::ScrollArea::vertical().id_salt("link-media-candidates-scroll").max_height(108.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() {
|
||||
let mark = match (ok, idm) {
|
||||
(true, Some(true)) => tl!("✔ same file"),
|
||||
(true, _) => tl!("✔ matches"),
|
||||
(false, Some(false)) => tl!("✖ different file"),
|
||||
_ => "✖",
|
||||
};
|
||||
let text = format!("{mark} {path}");
|
||||
let r = ui.selectable_label(
|
||||
d.candidate == Some(k),
|
||||
RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }),
|
||||
);
|
||||
let r = if problems.is_empty() { r } else { r.on_hover_text(problems) };
|
||||
push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text);
|
||||
if r.clicked() {
|
||||
d.candidate = Some(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12));
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
(l.momentary(), l.short_term(), l.integrated(), l.true_peak_dbtp())
|
||||
});
|
||||
let lr = Rect::from_min_max(pos2(rect.min.x + 4.0, rect.max.y - lufs_h), pos2(rect.max.x - 4.0, rect.max.y - 4.0));
|
||||
ui.painter().rect_filled(lr, 2.0, egui::Color32::from_rgb(0x14, 0x14, 0x14));
|
||||
ui.painter().rect_filled(lr, 2.0, t.meter_readout_bg);
|
||||
let rows = [("M", lufs_text(m)), ("S", lufs_text(s)), ("I", lufs_text(i)), ("TP", lufs_text(tp))];
|
||||
for (k, (label, val)) in rows.iter().enumerate() {
|
||||
let y = lr.min.y + 8.0 + k as f32 * 14.0;
|
||||
|
|
@ -84,7 +84,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
app.auto.add("audioMeters.loudness", lr, &format!("M {} S {} I {} LUFS TP {} dBTP", lufs_text(m), lufs_text(s), lufs_text(i), lufs_text(tp)));
|
||||
}
|
||||
ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, egui::Color32::BLACK);
|
||||
ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, t.meter_bg);
|
||||
let w = ((area.width() - 4.0 * (nch as f32 - 1.0)) / nch as f32).max(3.0);
|
||||
for c in 0..nch {
|
||||
let r = Rect::from_min_size(pos2(area.min.x + c as f32 * (w + 4.0), area.min.y), vec2(w, area.height()));
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap<u64, Vec<[f
|
|||
/// One bar per channel (level, peak hold) with a clip light on top; 5.1 meters carry channel names.
|
||||
fn draw_meters(ui: &egui::Ui, r: Rect, m: &[[f32; 2]], channels: usize, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, Color32::BLACK);
|
||||
p.rect_filled(r, 0.0, t.meter_bg);
|
||||
let n = channels.max(1);
|
||||
let gap = if n > 2 { 1.0 } else { 2.0 };
|
||||
let w = (r.width() - gap * (n as f32 - 1.0)) / n as f32;
|
||||
|
|
@ -138,7 +138,7 @@ fn draw_meters(ui: &egui::Ui, r: Rect, m: &[[f32; 2]], channels: usize, t: &Toke
|
|||
crate::widgets::meter_bar(p, br, v[0], v[1], t);
|
||||
// clip light
|
||||
let lr = Rect::from_min_size(pos2(br.min.x, r.min.y), vec2(w, 4.0));
|
||||
p.rect_filled(lr, 0.0, if v[1] >= -0.1 { RED } else { Color32::from_gray(0x30) });
|
||||
p.rect_filled(lr, 0.0, if v[1] >= -0.1 { RED } else { t.meter_clip_off });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,7 +305,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let ky = bar.min.y + (bar.height() - kh) * voff / max_voff;
|
||||
let knob = Rect::from_min_size(pos2(bar.min.x, ky), vec2(bar.width(), kh));
|
||||
let kr = ui.interact(knob, egui::Id::new("mixer-vscroll-knob"), Sense::drag());
|
||||
ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { Color32::from_gray(0x5a) });
|
||||
ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { t.fader_knob });
|
||||
if kr.dragged() {
|
||||
voff = (voff + kr.drag_delta().y * (need + 4.0) / bar.height()).clamp(0.0, max_voff);
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let kx = bar.min.x + (bar.width() - kw) * off / max_off;
|
||||
let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height()));
|
||||
let kr = ui.interact(knob, egui::Id::new("mixer-hscroll-knob"), Sense::drag());
|
||||
ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { Color32::from_gray(0x5a) });
|
||||
ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob });
|
||||
if kr.dragged() {
|
||||
off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off);
|
||||
}
|
||||
|
|
@ -369,7 +369,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
|||
let ap = format!("mixer.{label}");
|
||||
let is_master = id == MASTER_STRIP;
|
||||
let sid = json!(id.0);
|
||||
ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24));
|
||||
ui.painter().rect_filled(sr, 3.0, t.control_surface);
|
||||
ui.painter().line_segment([pos2(sr.max.x + 1.0, sr.min.y), pos2(sr.max.x + 1.0, sr.max.y)], Stroke::new(1.0, t.separator));
|
||||
let x = sr.min.x;
|
||||
let w = sr.width();
|
||||
|
|
@ -581,7 +581,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
|||
let kc = pos2(sr.center().x, y + 20.0);
|
||||
let kr = Rect::from_center_size(kc, vec2(38.0, 38.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)"));
|
||||
let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { Color32::from_gray(0xb0) };
|
||||
let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { t.control_handle_dim };
|
||||
ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring));
|
||||
let ang = (pan / 100.0) as f32 * 135f32.to_radians();
|
||||
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 14.0], Stroke::new(2.0, ring));
|
||||
|
|
@ -690,16 +690,16 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
|||
small_text(ui, pos2(x + 26.0, yy), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim);
|
||||
ui.painter().line_segment([pos2(x + 28.0, yy), pos2(x + 31.0, yy)], Stroke::new(1.0, t.text_faint));
|
||||
}
|
||||
ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10));
|
||||
ui.painter().rect_stroke(track, 2.0, Stroke::new(1.0, Color32::from_gray(0x50)), StrokeKind::Inside);
|
||||
ui.painter().rect_filled(track, 2.0, t.control_well);
|
||||
ui.painter().rect_stroke(track, 2.0, Stroke::new(1.0, t.control_border), StrokeKind::Inside);
|
||||
let vol = shown(app, &tr, LANE_VOLUME, cx.now);
|
||||
let cy = ypos(vol);
|
||||
let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0));
|
||||
let fresp =
|
||||
ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)"));
|
||||
let held = app.session.previews.live.get(id, LANE_VOLUME).is_some();
|
||||
let col = if fresp.dragged() || held { t.accent } else { Color32::from_gray(0xe0) };
|
||||
ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a));
|
||||
let col = if fresp.dragged() || held { t.accent } else { t.control_handle };
|
||||
ui.painter().rect_filled(cap, 3.0, t.control_handle_bg);
|
||||
ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, col), StrokeKind::Inside);
|
||||
ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, col));
|
||||
app.auto.add(&format!("{ap}.fader"), cap, &format!("Volume {}", db_text(vol)));
|
||||
|
|
@ -769,14 +769,14 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr
|
|||
let resp =
|
||||
ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)"));
|
||||
let p = ui.painter();
|
||||
p.rect_filled(sq, 3.0, Color32::from_gray(0x16));
|
||||
p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { Color32::from_gray(0x60) }), StrokeKind::Inside);
|
||||
p.rect_filled(sq, 3.0, t.panner_bg);
|
||||
p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { t.panner_border }), StrokeKind::Inside);
|
||||
let inner = sq.shrink(5.0);
|
||||
let to_pos =
|
||||
|x: f64, y: f64| pos2(inner.center().x + (x / 100.0) as f32 * inner.width() / 2.0, inner.center().y - (y / 100.0) as f32 * inner.height() / 2.0);
|
||||
// speakers
|
||||
for (x, y) in [(-100.0, 100.0), (0.0, 100.0), (100.0, 100.0), (-100.0, -100.0), (100.0, -100.0)] {
|
||||
p.rect_filled(Rect::from_center_size(to_pos(x, y), vec2(4.0, 4.0)), 1.0, Color32::from_gray(0x90));
|
||||
p.rect_filled(Rect::from_center_size(to_pos(x, y), vec2(4.0, 4.0)), 1.0, t.panner_speaker);
|
||||
}
|
||||
let puck = to_pos(px, py);
|
||||
p.circle_filled(puck, 4.0, if resp.dragged() { t.accent } else { t.hot_text });
|
||||
|
|
@ -900,7 +900,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let sr = Rect::from_min_max(pos2(x, rect.min.y + 4.0), pos2(x + STRIP_W, rect.max.y - 4.0));
|
||||
let label = format!("A{}", i + 1);
|
||||
let ap = format!("clipMixer.{label}");
|
||||
ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24));
|
||||
ui.painter().rect_filled(sr, 3.0, t.control_surface);
|
||||
let clip = tr.item_at(now).filter(|c| c.enabled);
|
||||
let active = clip.is_some();
|
||||
let dimc = |c: Color32| if active { c } else { c.gamma_multiply(0.4) };
|
||||
|
|
@ -930,9 +930,9 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let kc = pos2(sr.center().x, sr.min.y + 36.0);
|
||||
let kr = Rect::from_center_size(kc, vec2(30.0, 30.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag());
|
||||
ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(Color32::from_gray(0xb0))));
|
||||
ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(t.control_handle_dim)));
|
||||
let ang = (pan / 100.0) as f32 * 135f32.to_radians();
|
||||
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(Color32::from_gray(0xb0))));
|
||||
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(t.control_handle_dim)));
|
||||
small_text(ui, pos2(kc.x - 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "L", 11.0, dimc(t.hot_text));
|
||||
small_text(ui, pos2(kc.x + 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "R", 11.0, dimc(t.hot_text));
|
||||
small_text(ui, pos2(kc.x, kc.y + 26.0), Align2::CENTER_CENTER, &format!("{pan:.1}"), 11.5, dimc(t.hot_text));
|
||||
|
|
@ -989,13 +989,13 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let s = if d <= FADER_MIN_DB { "-∞".to_string() } else { format!("{}", d as i32) };
|
||||
small_text(ui, pos2(sr.min.x + 26.0, ypos(d)), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim);
|
||||
}
|
||||
ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10));
|
||||
ui.painter().rect_filled(track, 2.0, t.control_well);
|
||||
let cy = ypos(vol);
|
||||
let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0));
|
||||
let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag());
|
||||
ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a));
|
||||
ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(Color32::from_gray(0xe0))), StrokeKind::Inside);
|
||||
ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(Color32::from_gray(0xe0))));
|
||||
ui.painter().rect_filled(cap, 3.0, t.control_handle_bg);
|
||||
ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(t.control_handle)), StrokeKind::Inside);
|
||||
ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(t.control_handle)));
|
||||
app.auto.add(&format!("{ap}.fader"), cap, &format!("Clip volume {}", db_text(vol)));
|
||||
if fresp.dragged() && active && fresp.drag_delta().y != 0.0 {
|
||||
set(&mut acts, "volume", "level", pos_to_db(db_to_pos(vol) - fresp.drag_delta().y / track.height()), fresp.drag_started());
|
||||
|
|
@ -1023,6 +1023,29 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn meter_well_and_inactive_clip_light_follow_theme() {
|
||||
for kind in [crate::theme::ThemeKind::Dark, crate::theme::ThemeKind::Medium, crate::theme::ThemeKind::Light] {
|
||||
let t = Tokens::for_kind(kind);
|
||||
let ctx = egui::Context::default();
|
||||
let area = Rect::from_min_size(pos2(20.0, 20.0), vec2(40.0, 100.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw_meters(ui, area, &[[-90.0; 2]], 1, &t));
|
||||
out.textures_delta.clear();
|
||||
let rectangles: Vec<_> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Rect(r) => Some(r),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let well = rectangles.iter().find(|r| r.rect == area).expect("painted meter well");
|
||||
assert_eq!(well.fill, t.meter_bg, "meter well did not follow {kind:?}");
|
||||
let light = rectangles.iter().find(|r| r.rect.height() == 4.0).expect("painted clip light");
|
||||
assert_eq!(light.fill, t.meter_clip_off, "clip light did not follow {kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taper_roundtrips_and_is_monotonic() {
|
||||
let mut last = f64::NEG_INFINITY;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ pub mod timeline;
|
|||
pub mod timeline_automation;
|
||||
pub mod timeline_captions;
|
||||
pub mod timeline_hit;
|
||||
pub mod timeline_volume;
|
||||
pub mod tools;
|
||||
pub mod trim_monitor;
|
||||
pub mod voiceover;
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
|||
monitor_view::pan_input(app, ui, which, video_area, pic);
|
||||
}
|
||||
if which == Which::Program && show_picture {
|
||||
eyedropper(app, ui, &pic_resp, pic, target, rate, time);
|
||||
crate::panels::graphics::monitor_overlay(app, ui, pic, frame_size);
|
||||
crate::panels::masks::monitor_overlay(app, ui, pic, frame_size);
|
||||
}
|
||||
|
|
@ -305,8 +306,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
|||
// ---- controls row: timecode | zoom | res | wrench | duration
|
||||
let row1 = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.max.y - controls_h + 2.0), vec2(rect.width() - 28.0, 26.0));
|
||||
let tc = format_time(time, rate, drop_frame, TimeDisplay::Timecode, 48000);
|
||||
ui.painter().text(pos2(row1.min.x, row1.center().y), Align2::LEFT_CENTER, &tc, Tokens::timecode(), t.timecode);
|
||||
app.auto.add(&format!("{prefix}.timecode"), Rect::from_min_size(row1.min, vec2(110.0, row1.height())), &tc);
|
||||
let tc_rect = Rect::from_min_size(row1.min, vec2(110.0, row1.height()));
|
||||
let min = if which == Which::Program { Tick::ZERO } else { origin };
|
||||
match crate::widgets::timecode_field(ui, egui::Id::new((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) {
|
||||
Some(Ok(to)) => {
|
||||
let cmd = if which == Which::Program { "playhead.set" } else { "source.setPlayhead" };
|
||||
if let Err(e) = app.session.execute(cmd, json!({"time": to.0})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => app.ui.status = e,
|
||||
None => {}
|
||||
}
|
||||
app.auto.add(&format!("{prefix}.timecode"), tc_rect, &tc);
|
||||
let dur_tc = format_time(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - range_start, rate, drop_frame, TimeDisplay::Timecode, 48000);
|
||||
ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::timecode(), t.text_dim);
|
||||
// zoom + resolution dropdowns centred-ish
|
||||
|
|
@ -455,6 +467,99 @@ fn cpu_texture(
|
|||
}
|
||||
}
|
||||
|
||||
/// A click of an armed eyedropper waiting for its frame to render.
|
||||
#[derive(Clone)]
|
||||
struct EyedropperPick {
|
||||
/// The clicked point in the picture, 0..1 across and down.
|
||||
uv: (f32, f32),
|
||||
key: FrameKey,
|
||||
time: Tick,
|
||||
/// The project with the armed effect switched off, so a colour that effect keys out can be picked.
|
||||
project: std::sync::Arc<filmcraft_project::Project>,
|
||||
}
|
||||
|
||||
/// Output scale of the frame an eyedropper samples (half size is plenty and quick to render).
|
||||
const EYEDROPPER_SCALE: f32 = 0.5;
|
||||
|
||||
/// Eyedropper: while a colour parameter's eyedropper is armed (Effect Controls), the Program picture
|
||||
/// takes a crosshair and a click fills that parameter with the colour under the pointer. The colour is
|
||||
/// read from a render of the sequence at the playhead with the armed effect switched off (an Ultra Key
|
||||
/// has already removed the green it is asked to key); Esc disarms.
|
||||
fn eyedropper(app: &mut FilmcraftApp, ui: &mut egui::Ui, resp: &egui::Response, pic: Rect, target: Target, rate: filmcraft_time::FrameRate, time: Tick) {
|
||||
let Some(armed) = app.ui.eyedropper.clone() else { return };
|
||||
let ctx = ui.ctx().clone();
|
||||
let pending_id = egui::Id::new("eyedropper-pending");
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.eyedropper = None;
|
||||
ctx.data_mut(|d| d.remove::<EyedropperPick>(pending_id));
|
||||
return;
|
||||
}
|
||||
if resp.hovered() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let (Some(pos), Target::Sequence(seq)) = (resp.interact_pointer_pos(), target)
|
||||
&& pic.width() > 0.0
|
||||
&& pic.height() > 0.0
|
||||
{
|
||||
let uv = (((pos.x - pic.min.x) / pic.width()).clamp(0.0, 1.0), ((pos.y - pic.min.y) / pic.height()).clamp(0.0, 1.0));
|
||||
let mut project = (*app.session.project).clone();
|
||||
if let Some(e) =
|
||||
project.sequence_mut(seq).and_then(|q| q.find_item_mut(filmcraft_project::ClipId(armed.clip))).and_then(|(_, it)| it.effects.get_mut(armed.effect))
|
||||
{
|
||||
e.enabled = false;
|
||||
}
|
||||
let frame = rate.frame_at(time);
|
||||
// a revision of its own, so this render never replaces what the monitor caches
|
||||
let revision = app.session.revision ^ 0x5eed_d70b_0000_0000 ^ armed.clip.rotate_left(17) ^ armed.effect as u64;
|
||||
let key = FrameKey { target, frame, size: (EYEDROPPER_SCALE * 1000.0) as u32, revision, draft: false };
|
||||
ctx.data_mut(|d| d.insert_temp(pending_id, EyedropperPick { uv, key, time: rate.tick_of(frame), project: std::sync::Arc::new(project) }));
|
||||
}
|
||||
let Some(pick) = ctx.data(|d| d.get_temp::<EyedropperPick>(pending_id)) else { return };
|
||||
app.frames.request(pick.key, pick.time, EYEDROPPER_SCALE, &pick.project, 0);
|
||||
let Some(img) = app.frames.get(&pick.key) else {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
};
|
||||
ctx.data_mut(|d| d.remove::<EyedropperPick>(pending_id));
|
||||
app.ui.eyedropper = None;
|
||||
let Some(rgb) = sample_rgb(&img, pick.uv) else {
|
||||
app.ui.status = tl!("Eyedropper: no picture to sample").to_string();
|
||||
return;
|
||||
};
|
||||
// the parameter stores linear values, like the colour picker beside it
|
||||
let lin = egui::Rgba::from(Color32::from_rgb(rgb[0], rgb[1], rgb[2]));
|
||||
let mut params = json!({"clip": armed.clip, "effect": armed.effect, "param": armed.param, "value": [lin.r(), lin.g(), lin.b(), 1.0]});
|
||||
if let Some(k) = armed.mask {
|
||||
params["mask"] = json!(k);
|
||||
}
|
||||
if let Err(e) = app.session.execute("effects.setParam", params) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// The average colour of the 5×5 pixels around `uv` (0..1 across and down) of a frame, so a noisy
|
||||
/// green screen gives its true green. `None` for an empty frame.
|
||||
fn sample_rgb(img: &crate::frames::Rgba, uv: (f32, f32)) -> Option<[u8; 3]> {
|
||||
if img.w == 0 || img.h == 0 || img.px.len() < img.w.saturating_mul(img.h).saturating_mul(4) {
|
||||
return None;
|
||||
}
|
||||
let cx = ((uv.0 * img.w as f32) as usize).min(img.w - 1);
|
||||
let cy = ((uv.1 * img.h as f32) as usize).min(img.h - 1);
|
||||
let (mut sum, mut n) = ([0u32; 3], 0u32);
|
||||
for y in cy.saturating_sub(2)..=(cy + 2).min(img.h - 1) {
|
||||
for x in cx.saturating_sub(2)..=(cx + 2).min(img.w - 1) {
|
||||
let i = (y * img.w + x) * 4;
|
||||
for (c, s) in sum.iter_mut().enumerate() {
|
||||
*s += u32::from(*img.px.get(i + c)?);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let avg = |c: usize| u8::try_from(sum[c].checked_div(n)?).ok();
|
||||
Some([avg(0)?, avg(1)?, avg(2)?])
|
||||
}
|
||||
|
||||
pub fn quantize_scale(s: f32) -> f32 {
|
||||
// Buckets keep the cache effective while resizing the panel.
|
||||
let buckets = [1.0 / 32.0, 1.0 / 16.0, 1.0 / 8.0, 0.1875, 0.25, 0.375, 0.5, 0.75, 1.0];
|
||||
|
|
@ -674,8 +779,41 @@ fn source_nav(app: &mut FilmcraftApp, cmd: &str) {
|
|||
let t = match cmd {
|
||||
"src.goIn" => v.mark_in.unwrap_or(v.start),
|
||||
"src.goOut" => v.mark_out.unwrap_or(v.end - rate.frame_duration()),
|
||||
"src.stepBack" => cur - rate.frame_duration(),
|
||||
_ => cur + rate.frame_duration(),
|
||||
_ if rate.frame_duration() == Tick::ZERO => cur,
|
||||
"src.stepBack" => rate.tick_of(rate.frame_at(cur).saturating_sub(1)),
|
||||
_ => rate.tick_of(rate.frame_at(cur).saturating_add(1)),
|
||||
};
|
||||
let _ = app.session.execute("source.setPlayhead", json!({"time": t.0}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::frames::Rgba;
|
||||
|
||||
fn flat(w: usize, h: usize, rgba: [u8; 4]) -> Rgba {
|
||||
Rgba { w, h, px: rgba.iter().copied().cycle().take(w * h * 4).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eyedropper_averages_the_pixels_around_the_point() {
|
||||
let mut img = flat(8, 8, [0, 200, 0, 255]);
|
||||
// one noisy pixel next to the centre is averaged away, not picked
|
||||
img.px[(4 * 8 + 5) * 4 + 1] = 0;
|
||||
assert_eq!(sample_rgb(&img, (0.5, 0.5)), Some([0, 192, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eyedropper_clamps_to_the_picture_edges() {
|
||||
let img = flat(4, 3, [10, 20, 30, 255]);
|
||||
for uv in [(0.0, 0.0), (1.0, 1.0), (0.999, 0.0), (0.0, 0.999)] {
|
||||
assert_eq!(sample_rgb(&img, uv), Some([10, 20, 30]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eyedropper_rejects_empty_and_truncated_frames() {
|
||||
assert_eq!(sample_rgb(&Rgba { w: 0, h: 0, px: vec![] }, (0.5, 0.5)), None);
|
||||
assert_eq!(sample_rgb(&Rgba { w: 4, h: 4, px: vec![0; 10] }, (0.5, 0.5)), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue