Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83481c146f | ||
|
|
5c8471c9f4 | ||
|
|
37361900a9 | ||
|
|
e89feeac4d | ||
|
|
555bfe7c18 | ||
|
|
5dd09386f6 | ||
|
|
475c294678 | ||
|
|
51026bd2b2 | ||
|
|
d886d2f322 | ||
|
|
39ef62607d | ||
|
|
8dfa8fff4c | ||
|
|
8a795324f4 | ||
|
|
eb3ca3e811 | ||
|
|
950879ef10 | ||
|
|
7377def476 | ||
|
|
5bc07026c7 | ||
|
|
a3b077877f | ||
|
|
a407099019 | ||
|
|
bccc3dd031 | ||
|
|
bd40764756 | ||
|
|
e6d693cc24 | ||
|
|
ac906666d9 | ||
|
|
6a9fdce2ef | ||
|
|
47c6b03681 | ||
|
|
39d81aee14 | ||
|
|
2f49131f64 | ||
|
|
bab130c358 | ||
|
|
61ab6f5c7d | ||
|
|
e659671bdf | ||
|
|
bc1523a33f | ||
|
|
136424dc93 | ||
|
|
6bf2756cde | ||
|
|
c60d3f9c4c | ||
|
|
7fee797ee7 | ||
|
|
508a5a933f | ||
|
|
5012fbd598 | ||
|
|
1f7e4f802e | ||
|
|
a8e4c815eb | ||
|
|
4a0ab32480 | ||
|
|
4df7e75085 | ||
|
|
f06bfc4648 | ||
|
|
7eb4f1fc7b | ||
|
|
ff8aa8749d | ||
|
|
8f74d18829 | ||
|
|
6de389c75a | ||
|
|
4aa3f79953 | ||
|
|
6899f15e51 | ||
|
|
5bf2203bb8 | ||
|
|
d3ee1dfd22 | ||
|
|
0e3aebee5d | ||
|
|
22229df105 | ||
|
|
285da5c66b | ||
|
|
51ecd70763 | ||
|
|
9d3975a697 | ||
|
|
4f3e119ac2 | ||
|
|
5d1165d06b | ||
|
|
b999b69f07 | ||
|
|
4486652f07 | ||
|
|
517b6b041b | ||
|
|
ac8279d293 | ||
|
|
ada55eb625 | ||
|
|
adfd9a66de | ||
|
|
9c06722119 | ||
|
|
cad16825e3 | ||
|
|
8ee7658303 | ||
|
|
0f70015b93 | ||
|
|
a55d20c454 |
No files matched your search
@@ -47,6 +47,10 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/filmcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/filmcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.filmcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.filmcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
|
||||
|
||||
@@ -112,6 +112,9 @@ jobs:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -221,8 +224,9 @@ jobs:
|
||||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen crate version exactly (WASM_BINDGEN in xtask/src/main.rs).
|
||||
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|
||||
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
|
||||
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
fi
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'FilmCraft'
|
||||
foreach ($exe in 'filmcraft.exe', 'filmcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'filmcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "filmcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok filmcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'filmcraft.exe')) { throw 'uninstall left filmcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
@@ -120,6 +120,12 @@ See [§0](#0-never-crash). It outranks every other engineering rule.
|
||||
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
|
||||
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
|
||||
|
||||
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
|
||||
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
|
||||
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
|
||||
Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified,
|
||||
as PhotoCraft does with `cargo xtask corpus`.
|
||||
|
||||
## See also
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/contributing.md](docs/contributing.md): setup, gates, commits, how to add things
|
||||
|
||||
Generated
+46
-46
@@ -1655,7 +1655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1677,7 +1677,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1687,7 +1687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1695,14 +1695,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
@@ -1718,7 +1718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1729,14 +1729,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
@@ -1749,11 +1749,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
@@ -1770,7 +1770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
@@ -1802,7 +1802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1810,7 +1810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1820,7 +1820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
@@ -1832,7 +1832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -1863,7 +1863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-audio-dsp",
|
||||
@@ -1892,7 +1892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
@@ -1904,7 +1904,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
@@ -1914,7 +1914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1922,7 +1922,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -1939,7 +1939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -1956,7 +1956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1966,7 +1966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1976,7 +1976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1986,7 +1986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
@@ -2003,7 +2003,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2014,7 +2014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
@@ -2022,7 +2022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -2035,7 +2035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
@@ -2044,7 +2044,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2052,28 +2052,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-platform"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
@@ -2091,7 +2091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
@@ -2105,7 +2105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2115,7 +2115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -2135,7 +2135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
@@ -2144,7 +2144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2162,14 +2162,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
@@ -2180,7 +2180,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2188,7 +2188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2230,7 +2230,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7211,7 +7211,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+7
-1
@@ -36,7 +36,7 @@ before choosing work.
|
||||
| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. |
|
||||
| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. |
|
||||
| **Export** | ~92% | **~70%** | Delivery: H.264 only (ProRes, DNxHR and image sequences cover mastering). No HEVC or AV1 export. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS only (VideoToolbox, #33). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects, export and encoding are CPU. Linux and Windows have no hardware path. 8K and AV1 aren't real-time. The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS only (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects, export and encoding are CPU. Linux and Windows have no hardware path. 8K and AV1 aren't real-time. The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. |
|
||||
| **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. |
|
||||
| **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. |
|
||||
@@ -159,6 +159,8 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28).
|
||||
|
||||
- **2026-10-05:** honest assessment added (checklist ~87% vs ready for real work ~50–60%). Hardware acceleration started (#30): blend modes on the GPU (#32); VideoToolbox decode + `platform` crate, the one crate allowed `unsafe` (#33). Community fixes landed: AIFF import, export start/end ranges, transitions following ripple trims, Slide moving linked audio (#5–#8).
|
||||
|
||||
- **2026-10-04 (later):** interchange + MXF export (M11.4/M6.6): own compound-file container (`crates/cfb`), AAF Edit Protocol import/export (embedded/linked/consolidated audio, video mixdown, handles, breakout to mono), OMF 2.0 export (Bento), MXF OP1a/OP-Atom writer (DNxHR/ProRes/H.264/PCM) as export formats. 1605 tests.
|
||||
@@ -200,3 +202,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
- **2026-09-30 (midday):** HEVC decoder, Matroska/WebM demuxer and audio DSP merged; HEVC import wired; asset rules (AGENTS.md, ATTRIBUTION.md, `cargo xtask assets`); README with hero screenshot and the Craft family.
|
||||
- **2026-09-30 (late morning):** keyframe value/velocity graphs; xtask gates; H.264 MP4 export; Lumetri curves, wheels, looks, HSL secondary.
|
||||
- **2026-09-30 (early morning):** H.264 decoder, ProRes, AAC; GPU compositor; MCP; Premiere 26 visual fidelity pass.
|
||||
|
||||
### Interface localization
|
||||
|
||||
Edit > Language switches English/Japanese and persists in UI state. Commands `app.language.english` and `app.language.japanese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses a Japanese font already installed on the system (none is bundled); without one, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
|
||||
@@ -0,0 +1,171 @@
|
||||
//! The desktop app's command line (`filmcraft --help`).
|
||||
|
||||
/// `filmcraft --help`.
|
||||
pub const USAGE: &str = "\
|
||||
Usage: filmcraft [options] [project.fcproj | media files...]
|
||||
|
||||
Options:
|
||||
--control <port> start the localhost JSON-lines control server
|
||||
(also: FILMCRAFT_CONTROL_PORT)
|
||||
--demo open the demo project
|
||||
--empty start with an empty project
|
||||
--recover recover the newest unsaved changes without asking
|
||||
--no-recover start without asking about unsaved changes
|
||||
--data-dir <dir> folder for auto-save, crash recovery and logs
|
||||
(also: FILMCRAFT_DATA_DIR)
|
||||
--version print the version and exit
|
||||
-h, --help print this help and exit
|
||||
";
|
||||
|
||||
/// What the command line asks for.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Cli {
|
||||
Run(Launch),
|
||||
Help,
|
||||
Version,
|
||||
}
|
||||
|
||||
/// Options of a normal start.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Launch {
|
||||
pub control_port: Option<u16>,
|
||||
/// A project and / or media files to open.
|
||||
pub files: Vec<String>,
|
||||
pub demo: bool,
|
||||
/// `--demo` / `--empty` given: skip Settings ▸ General ▸ At Startup.
|
||||
pub startup_flag: bool,
|
||||
pub recover: Option<bool>,
|
||||
pub data_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Parse the arguments after the program name. `env_control_port` is the value of
|
||||
/// `FILMCRAFT_CONTROL_PORT`, used when `--control` is not given (an empty value counts as unset).
|
||||
///
|
||||
/// An option we don't know, or one without a usable value, is an error: opening a window for a
|
||||
/// mistyped flag helps nobody, and starting without the control server that was asked for leaves
|
||||
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
|
||||
/// too, and for a port from the environment exactly as for one from `--control`.
|
||||
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
|
||||
let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
|
||||
let (mut help, mut version) = (false, false);
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"-h" | "--help" => help = true,
|
||||
"--version" => version = true,
|
||||
"--control" => {
|
||||
let port = args.next().ok_or("--control needs a port number")?;
|
||||
l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?);
|
||||
}
|
||||
"--demo" => (l.demo, l.startup_flag) = (true, true),
|
||||
"--empty" => (l.demo, l.startup_flag) = (false, true),
|
||||
"--recover" => l.recover = Some(true),
|
||||
"--no-recover" => l.recover = Some(false),
|
||||
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
|
||||
// everything after `--` is a file, whatever it looks like
|
||||
"--" => l.files.extend(args.by_ref()),
|
||||
// macOS adds a process serial number when it launches an app bundle
|
||||
_ if a.starts_with("-psn_") => {}
|
||||
_ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")),
|
||||
_ => l.files.push(a),
|
||||
}
|
||||
}
|
||||
if help {
|
||||
return Ok(Cli::Help);
|
||||
}
|
||||
if version {
|
||||
return Ok(Cli::Version);
|
||||
}
|
||||
if l.control_port.is_none()
|
||||
&& let Some(port) = env_control_port.as_deref().map(str::trim).filter(|p| !p.is_empty())
|
||||
{
|
||||
l.control_port = Some(port.parse().map_err(|_| format!("FILMCRAFT_CONTROL_PORT needs a port number, got `{port}`"))?);
|
||||
}
|
||||
Ok(Cli::Run(l))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_env(env: Option<&str>, args: &[&str]) -> Result<Cli, String> {
|
||||
parse(env.map(str::to_string), args.iter().map(|a| a.to_string()))
|
||||
}
|
||||
|
||||
fn parse_args(args: &[&str]) -> Result<Cli, String> {
|
||||
parse_env(None, args)
|
||||
}
|
||||
|
||||
fn launch(args: &[&str]) -> Launch {
|
||||
match parse_args(args) {
|
||||
Ok(Cli::Run(l)) => l,
|
||||
other => panic!("{args:?} should start the app, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `filmcraft --help` used to open the app with "--help" as a media file to import.
|
||||
#[test]
|
||||
fn help_and_version_do_not_start_the_app() {
|
||||
assert_eq!(parse_args(&["--help"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_args(&["-h"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_args(&["--empty", "--help"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_args(&["--version"]), Ok(Cli::Version));
|
||||
assert_eq!(parse_args(&["--version", "--help"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_args(&["a.mp4", "--version"]), Ok(Cli::Version));
|
||||
for flag in ["--control", "--demo", "--empty", "--recover", "--no-recover", "--data-dir", "--version", "--help"] {
|
||||
assert!(USAGE.contains(flag), "usage lists {flag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_options_are_errors() {
|
||||
assert_eq!(parse_args(&["--frobnicate"]), Err("unknown option `--frobnicate`".to_string()));
|
||||
assert!(parse_args(&["a.mp4", "-x"]).is_err());
|
||||
assert!(parse_args(&["--Help"]).is_err());
|
||||
assert!(parse_args(&["--control"]).is_err(), "missing port");
|
||||
assert!(parse_args(&["--control", "abc"]).unwrap_err().contains("abc"));
|
||||
assert!(parse_args(&["--control", "70000"]).is_err(), "not a port");
|
||||
assert!(parse_args(&["--data-dir"]).is_err(), "missing folder");
|
||||
// the whole line is checked, whatever comes first
|
||||
assert_eq!(parse_args(&["--version", "--bogus"]), Err("unknown option `--bogus`".to_string()));
|
||||
assert_eq!(parse_args(&["--bogus", "--version"]), Err("unknown option `--bogus`".to_string()));
|
||||
assert_eq!(parse_args(&["--help", "--bogus"]), Err("unknown option `--bogus`".to_string()));
|
||||
assert!(parse_args(&["--help", "--control"]).is_err());
|
||||
}
|
||||
|
||||
/// A bad `FILMCRAFT_CONTROL_PORT` used to start the app without the control server, silently.
|
||||
#[test]
|
||||
fn a_bad_port_in_the_environment_is_an_error_like_a_bad_control_flag() {
|
||||
for bad in ["abc", "70000", "-1", "98 76", "9876x", "\u{fffd}"] {
|
||||
let e = parse_env(Some(bad), &[]).unwrap_err();
|
||||
assert!(e.contains("FILMCRAFT_CONTROL_PORT") && e.contains(bad), "{bad}: {e}");
|
||||
assert!(parse_env(Some(bad), &["--empty", "a.mp4"]).is_err(), "{bad}");
|
||||
// the flag wins, so the environment's value is not looked at; nor for --help / --version
|
||||
assert_eq!(parse_env(Some(bad), &["--control", "9"]).map(|c| matches!(c, Cli::Run(l) if l.control_port == Some(9))), Ok(true), "{bad}");
|
||||
assert_eq!(parse_env(Some(bad), &["--help"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_env(Some(bad), &["--version"]), Ok(Cli::Version));
|
||||
}
|
||||
// unset or empty: no control server
|
||||
assert_eq!(launch(&[]).control_port, None);
|
||||
assert_eq!(parse_env(Some(""), &[]), Ok(Cli::Run(launch(&[]))));
|
||||
assert_eq!(parse_env(Some(" "), &[]), Ok(Cli::Run(launch(&[]))));
|
||||
// a good one is the default
|
||||
assert_eq!(parse_env(Some("1234"), &[]), Ok(Cli::Run(Launch { control_port: Some(1234), ..launch(&[]) })));
|
||||
assert_eq!(parse_env(Some("1234"), &["--control", "9"]).map(|c| matches!(c, Cli::Run(l) if l.control_port == Some(9))), Ok(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_options_and_files_parse_as_before() {
|
||||
let none = launch(&[]);
|
||||
assert_eq!(none, Launch { control_port: None, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None });
|
||||
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
|
||||
assert_eq!(l.control_port, Some(9876));
|
||||
assert_eq!((l.demo, l.startup_flag, l.recover), (false, true, Some(false)));
|
||||
assert_eq!(l.data_dir, Some(std::path::PathBuf::from("/tmp/fc")));
|
||||
assert_eq!(l.files, ["cut.fcproj", "a.mp4"]);
|
||||
assert_eq!((launch(&["--demo"]).demo, launch(&["--demo"]).startup_flag, launch(&["--recover"]).recover), (true, true, Some(true)));
|
||||
// `--` ends the options; a lone `-` and macOS' process serial number are not options
|
||||
assert_eq!(launch(&["--", "--odd name.mov", "-h"]).files, ["--odd name.mov", "-h"]);
|
||||
assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]);
|
||||
}
|
||||
}
|
||||
+63
-28
@@ -1,23 +1,26 @@
|
||||
//! FilmCraft desktop app.
|
||||
//!
|
||||
//! Usage: `filmcraft [--control <port>] [--demo|--empty] [--recover|--no-recover] [--data-dir <dir>]
|
||||
//! [project.fcproj | media files…]`
|
||||
//! [project.fcproj | media files…]`; `--help` prints the options, `--version` the version. An
|
||||
//! unknown option is an error (exit code 2), not a file to open.
|
||||
//!
|
||||
//! Without a project, `--demo` or `--empty`, Settings ▸ General ▸ At Startup decides: Show Home
|
||||
//! (the demo project), Open Most Recent, or an empty project.
|
||||
//!
|
||||
//! `--control <port>` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
||||
//! see `filmcraft_ui_egui::control` for the methods.
|
||||
//! see `filmcraft_ui_egui::control` for the methods. A port that is not a number is an error from
|
||||
//! either source.
|
||||
//!
|
||||
//! Auto-save and the crash-recovery journal run in every session (data in `--data-dir`, else
|
||||
//! `FILMCRAFT_DATA_DIR`, else the per-user application data folder). If a previous session died
|
||||
//! with unsaved changes the app asks to recover them; `--recover` recovers the newest without
|
||||
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
|
||||
//! Unsaved Changes…).
|
||||
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod app_nap;
|
||||
mod args;
|
||||
mod audio;
|
||||
mod audio_in;
|
||||
mod control_server;
|
||||
@@ -25,6 +28,7 @@ mod control_server;
|
||||
mod native_menu;
|
||||
mod window_raise;
|
||||
|
||||
use args::{Cli, Launch};
|
||||
use chrono::TimeZone;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir};
|
||||
@@ -45,37 +49,47 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
app_nap::disable();
|
||||
let mut control_port: Option<u16> = std::env::var("FILMCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut demo = true;
|
||||
// --demo / --empty given: skip Settings ▸ General ▸ At Startup
|
||||
let mut startup_flag = false;
|
||||
let mut recover: Option<bool> = None;
|
||||
let mut data_dir = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--demo" => (demo, startup_flag) = (true, true),
|
||||
"--empty" => (demo, startup_flag) = (false, true),
|
||||
"--recover" => recover = Some(true),
|
||||
"--no-recover" => recover = Some(false),
|
||||
"--data-dir" => data_dir = args.next().map(std::path::PathBuf::from),
|
||||
"--version" => {
|
||||
println!("filmcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
/// Print `text` (stdout, or stderr for an error). A Windows release build has no console
|
||||
/// (`windows_subsystem = "windows"`), so there it is also shown in a message box: otherwise `--help`
|
||||
/// would print nothing and a bad option would quit without a word.
|
||||
fn tell(text: &str, error: bool) {
|
||||
if error {
|
||||
eprint!("{text}");
|
||||
} else {
|
||||
print!("{text}");
|
||||
}
|
||||
#[cfg(all(target_os = "windows", not(debug_assertions)))]
|
||||
{
|
||||
let level = if error { rfd::MessageLevel::Error } else { rfd::MessageLevel::Info };
|
||||
let _ = rfd::MessageDialog::new().set_level(level).set_title("FilmCraft").set_description(text).show();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
|
||||
let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
|
||||
Ok(Cli::Run(l)) => l,
|
||||
Ok(Cli::Help) => {
|
||||
tell(args::USAGE, false);
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Cli::Version) => {
|
||||
tell(&format!("filmcraft {}\n", env!("CARGO_PKG_VERSION")), false);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
tell(&format!("filmcraft: {e}\n\n{}", args::USAGE), true);
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
app_nap::disable();
|
||||
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
|
||||
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
|
||||
filmcraft_ui_egui::crash::install(data_dir.clone().or_else(default_data_dir).map(|d| d.join("Logs")));
|
||||
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
|
||||
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
|
||||
log::info!("hardware decoding: {:?}", filmcraft_platform::register());
|
||||
register_hardware_decoders();
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FilmCraft")
|
||||
@@ -225,6 +239,15 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> {
|
||||
cmd.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
|
||||
}
|
||||
|
||||
/// Put the OS hardware video decoders in front of our own. Registered in a statement of its own:
|
||||
/// a log macro does not evaluate its arguments while no logger takes its level, which left the
|
||||
/// hardware decoders out of every run.
|
||||
fn register_hardware_decoders() -> filmcraft_platform::Availability {
|
||||
let hardware = filmcraft_platform::register();
|
||||
log::info!("hardware decoding: {hardware:?}");
|
||||
hardware
|
||||
}
|
||||
|
||||
/// Local UTC offset at a unix time (auto-save file names and recovery times use local time).
|
||||
fn local_offset(unix: i64) -> i32 {
|
||||
chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0)
|
||||
@@ -243,3 +266,15 @@ fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
|
||||
let _ = agent;
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Start-up registers the hardware decoders whether or not anything is logged (no logger is
|
||||
/// installed here, as in a normal run).
|
||||
#[test]
|
||||
fn startup_registers_the_hardware_decoders_without_a_logger() {
|
||||
assert!(!log::log_enabled!(log::Level::Info));
|
||||
let hardware = super::register_hardware_decoders();
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(target_os = "macos"), "{hardware:?}");
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,12 @@ pub type ShortcutUpdater = Box<dyn FnMut(&[Item])>;
|
||||
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, ShortcutUpdater) {
|
||||
let items = menu_items(app);
|
||||
let mut native: Vec<(String, MenuItem)> = Vec::new();
|
||||
let mut titles: Vec<(String, Submenu)> = Vec::new();
|
||||
let bar = Menu::new();
|
||||
let app_menu = Submenu::new("FilmCraft", true);
|
||||
// FilmCraft ▸ Settings ▸ <category> (Premiere's app-menu layout; General is Cmd+,)
|
||||
let settings = Submenu::new("Settings", true);
|
||||
let settings = Submenu::new(app.ui.language.tr("Settings"), true);
|
||||
titles.push(("Settings".into(), settings.clone()));
|
||||
for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) {
|
||||
let label = it.label.trim_end_matches('…').to_string();
|
||||
let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel));
|
||||
@@ -48,7 +50,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
]);
|
||||
let _ = bar.append(&app_menu);
|
||||
for top in MENUS {
|
||||
let sub = Submenu::new(top, true);
|
||||
let sub = Submenu::new(app.ui.language.tr(top), true);
|
||||
titles.push((top.into(), sub.clone()));
|
||||
let mine: Vec<&Item> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top) && !i.id.starts_with("app.settings.")).collect();
|
||||
// submenus by path prefix (e.g. Clip ▸ Video Options ▸ Time Interpolation), created where
|
||||
// their first item appears
|
||||
@@ -62,7 +65,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
parent = match subs.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, s)) => s.clone(),
|
||||
None => {
|
||||
let s = Submenu::new(&it.path[depth], true);
|
||||
let s = Submenu::new(app.ui.language.tr(&it.path[depth]), true);
|
||||
titles.push((it.path[depth].clone(), s.clone()));
|
||||
let _ = parent.append(&s);
|
||||
subs.push((key, s.clone()));
|
||||
s
|
||||
@@ -88,8 +92,17 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
}
|
||||
});
|
||||
let update: ShortcutUpdater = Box::new(move |items: &[Item]| {
|
||||
let language = if items.iter().any(|it| it.id == "app.language.japanese" && it.checked == Some(true)) {
|
||||
filmcraft_ui_egui::i18n::Language::Ja
|
||||
} else {
|
||||
filmcraft_ui_egui::i18n::Language::En
|
||||
};
|
||||
for (title, submenu) in &titles {
|
||||
submenu.set_text(language.tr(title));
|
||||
}
|
||||
for (id, mi) in &native {
|
||||
if let Some(it) = items.iter().find(|i| &i.id == id) {
|
||||
mi.set_text(&it.label);
|
||||
let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod bridge;
|
||||
pub mod long_job;
|
||||
pub mod server;
|
||||
|
||||
pub use bridge::BridgeClient;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Long exports over MCP (docs/agents.md § Long exports): `file.exportMedia` with `wait: true`
|
||||
//! runs as a background engine job; the server reports its progress as MCP
|
||||
//! `notifications/progress` (when the request carried a `progressToken`) and stops it on
|
||||
//! `notifications/cancelled`, deleting the partial output. The session lock is only held for
|
||||
//! short polls, so other requests are answered while the export runs.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use rmcp::RoleServer;
|
||||
use rmcp::model::{CallToolResult, ContentBlock as Content, ProgressNotificationParam, ProgressToken};
|
||||
use rmcp::service::{Peer, RequestContext};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::server::FilmcraftMcp;
|
||||
|
||||
/// Engine commands that block until a render is written when called with `wait: true`.
|
||||
pub const LONG_COMMANDS: &[&str] = &["file.exportMedia"];
|
||||
|
||||
/// How often the job is polled (and at most how often progress is reported).
|
||||
const POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Whether `command_run {id, params}` is a long call this module runs.
|
||||
pub fn is_long(id: &str, params: &Value) -> bool {
|
||||
LONG_COMMANDS.contains(&id) && params.get("wait").and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// Sends `notifications/progress` for one request; progress only ever increases.
|
||||
struct Reporter {
|
||||
peer: Peer<RoleServer>,
|
||||
token: Option<ProgressToken>,
|
||||
last: f64,
|
||||
}
|
||||
|
||||
impl Reporter {
|
||||
async fn report(&mut self, done: f64, total: f64, message: &str) {
|
||||
let Some(token) = self.token.clone() else { return };
|
||||
if total <= 0.0 || done <= self.last {
|
||||
return;
|
||||
}
|
||||
self.last = done;
|
||||
let mut p = ProgressNotificationParam::new(token, done).with_total(total);
|
||||
if !message.is_empty() {
|
||||
p = p.with_message(message);
|
||||
}
|
||||
let _ = self.peer.notify_progress(p).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl FilmcraftMcp {
|
||||
/// Run `id` (a [`LONG_COMMANDS`] entry, `wait: true`) as a job, reporting progress and
|
||||
/// honouring cancellation.
|
||||
pub(crate) async fn run_long(&self, id: &str, mut params: Value, context: &RequestContext<RoleServer>) -> CallToolResult {
|
||||
let started = SystemTime::now();
|
||||
if let Some(p) = params.as_object_mut() {
|
||||
p.insert("wait".into(), json!(false));
|
||||
}
|
||||
let start = match self.run(id, params).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return CallToolResult::error(vec![Content::text(e.to_string())]),
|
||||
};
|
||||
let Some(job) = start.get("job").and_then(Value::as_u64) else {
|
||||
return CallToolResult::error(vec![Content::text(format!("{id} started no job: {start}"))]);
|
||||
};
|
||||
let path = start.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let mut rep = Reporter { peer: context.peer.clone(), token: context.meta.get_progress_token(), last: 0.0 };
|
||||
loop {
|
||||
let state = self.job_state(job).await;
|
||||
let finished = state.as_ref().is_none_or(|j| j["finished"].as_bool() == Some(true));
|
||||
if let Some(j) = &state {
|
||||
let (done, total) = (j["done"].as_f64().unwrap_or(0.0), j["total"].as_f64().unwrap_or(0.0));
|
||||
rep.report(done, total, j["status"].as_str().unwrap_or_default()).await;
|
||||
}
|
||||
if finished {
|
||||
let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null);
|
||||
if let Some(e) = result.get("error").and_then(Value::as_str) {
|
||||
return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]);
|
||||
}
|
||||
let mut out = start;
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("result".into(), result);
|
||||
}
|
||||
return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]);
|
||||
}
|
||||
tokio::select! {
|
||||
_ = context.ct.cancelled() => break,
|
||||
_ = tokio::time::sleep(POLL) => {}
|
||||
}
|
||||
}
|
||||
// Cancelled: stop the encode at the next batch, wait for the worker, delete the partial file.
|
||||
let _ = self.run("jobs.cancel", json!({"job": job})).await;
|
||||
for _ in 0..600 {
|
||||
if self.job_state(job).await.is_none_or(|j| j["finished"].as_bool() == Some(true)) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(POLL).await;
|
||||
}
|
||||
remove_partial(&path, started);
|
||||
CallToolResult::error(vec![Content::text("cancelled")])
|
||||
}
|
||||
|
||||
/// The job's `jobs.list` entry.
|
||||
async fn job_state(&self, job: u64) -> Option<Value> {
|
||||
let jobs = self.run("jobs.list", json!({})).await.ok()?;
|
||||
jobs.as_array()?.iter().find(|j| j["id"].as_u64() == Some(job)).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered
|
||||
/// stills of an image sequence (`<stem><digits>.<same extension>`) and the caption sidecar
|
||||
/// (`<stem>.srt` / `<stem>.vtt`), each only if it was written since `since`. Other files that
|
||||
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, …) are never touched, even when they
|
||||
/// were saved during the export.
|
||||
pub fn remove_partial(path: &str, since: SystemTime) {
|
||||
let p = Path::new(path);
|
||||
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
|
||||
return;
|
||||
};
|
||||
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
// file times are coarser than the clock
|
||||
let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since);
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if is_export_output(name, file, stem, ext) && e.metadata().is_ok_and(|m| m.is_file() && m.modified().is_ok_and(|t| t >= since)) {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is a file an export to `file` (`stem` + `.ext`) writes.
|
||||
fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool {
|
||||
if name == file {
|
||||
return true;
|
||||
}
|
||||
let Some(rest) = name.strip_prefix(stem) else { return false };
|
||||
if let Some(sidecar) = rest.strip_prefix('.') {
|
||||
return sidecar.eq_ignore_ascii_case("srt") || sidecar.eq_ignore_ascii_case("vtt");
|
||||
}
|
||||
// image sequence: one or more digits, then the output's own extension
|
||||
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
|
||||
digits > 0 && !ext.is_empty() && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_exports_own_files_are_removed() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let started = SystemTime::now();
|
||||
let ours = ["trailer.png", "trailer000.png", "trailer123.png", "trailer.srt", "trailer.vtt"];
|
||||
// same stem, written during the export, but not the export's
|
||||
let theirs = ["trailer.aep", "trailer2.psd", "trailer.docx", "trailer000.jpg", "trailers.png", "trailer.png.bak", "other.png"];
|
||||
for f in ours.iter().chain(theirs.iter()) {
|
||||
std::fs::write(dir.join(f), b"x").unwrap();
|
||||
}
|
||||
remove_partial(&dir.join("trailer.png").to_string_lossy(), started);
|
||||
for f in ours {
|
||||
assert!(!dir.join(f).exists(), "{f} should be removed");
|
||||
}
|
||||
for f in theirs {
|
||||
assert!(dir.join(f).exists(), "{f} must not be removed");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_older_than_the_export_are_kept() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-old-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("clip.mp4"), b"x").unwrap();
|
||||
remove_partial(&dir.join("clip.mp4").to_string_lossy(), SystemTime::now() + Duration::from_secs(60));
|
||||
assert!(dir.join("clip.mp4").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+546
-28
@@ -1,6 +1,6 @@
|
||||
//! The MCP server. Tool names are snake_case.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use base64::Engine as _;
|
||||
use filmcraft_engine::Session;
|
||||
@@ -19,6 +19,19 @@ pub enum Backend {
|
||||
Bridge(Arc<BridgeClient>),
|
||||
}
|
||||
|
||||
/// A panic payload as text.
|
||||
fn panic_message(p: Box<dyn std::any::Any + Send>) -> String {
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
/// A failed blocking task: a panic is reported as an internal error and the session stays usable.
|
||||
fn join_error(e: tokio::task::JoinError) -> AutomationError {
|
||||
match e.try_into_panic() {
|
||||
Ok(p) => AutomationError::Other(format!("internal error: {}", panic_message(p))),
|
||||
Err(e) => AutomationError::Other(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FilmcraftMcp {
|
||||
backend: Arc<Backend>,
|
||||
@@ -44,6 +57,24 @@ pub struct RunParams {
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BatchStep {
|
||||
/// Command id.
|
||||
pub id: String,
|
||||
/// Parameters object.
|
||||
#[serde(default)]
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BatchParams {
|
||||
/// Commands to run in order.
|
||||
pub steps: Vec<BatchStep>,
|
||||
/// Stop at the first failing step (default true).
|
||||
#[serde(default)]
|
||||
pub stop_on_error: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct RenderParams {
|
||||
/// Timeline time in seconds (default: the playhead).
|
||||
@@ -148,9 +179,60 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
pub async fn serve_stdio(self) -> Result<(), AutomationError> {
|
||||
let running = self.serve(rmcp::transport::stdio()).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
|
||||
running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
Ok(())
|
||||
self.serve_io(tokio::io::stdin(), tokio::io::stdout()).await
|
||||
}
|
||||
|
||||
/// Serve MCP over any line stream. A line that isn't JSON gets a JSON-RPC parse error
|
||||
/// (`-32700`, id null) instead of being dropped silently; the rest go to the rmcp service. One
|
||||
/// writer task owns `output`, so replies never interleave.
|
||||
pub async fn serve_io(
|
||||
self,
|
||||
input: impl tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
output: impl tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
) -> Result<(), AutomationError> {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
let (mut to_service, service_in) = tokio::io::duplex(1 << 20);
|
||||
let (service_out, from_service) = tokio::io::duplex(1 << 20);
|
||||
let (err_tx, mut err_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let reader = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(input).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(_) => {
|
||||
if to_service.write_all(format!("{line}\n").as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let reply = json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("parse error: {e}")}});
|
||||
let _ = err_tx.send(reply.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// EOF: closing the pipe ends the service
|
||||
let _ = to_service.shutdown().await;
|
||||
});
|
||||
let writer = tokio::spawn(async move {
|
||||
let mut output = output;
|
||||
let mut replies = BufReader::new(from_service).lines();
|
||||
loop {
|
||||
let line = tokio::select! {
|
||||
l = replies.next_line() => match l { Ok(Some(l)) => l, _ => break },
|
||||
Some(e) = err_rx.recv() => e,
|
||||
};
|
||||
if output.write_all(format!("{line}\n").as_bytes()).await.is_err() || output.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let running = self.serve((service_in, service_out)).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
|
||||
let r = running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()));
|
||||
reader.abort();
|
||||
let _ = writer.await;
|
||||
r.map(|_| ())
|
||||
}
|
||||
|
||||
/// Run an engine command on whichever backend.
|
||||
@@ -160,16 +242,24 @@ impl FilmcraftMcp {
|
||||
let s = s.clone();
|
||||
let id = id.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut g = s.lock().map_err(|_| AutomationError::Other("session lock poisoned".into()))?;
|
||||
// A command that panicked earlier poisoned the lock; the session is still usable.
|
||||
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.execute(&id, params).map_err(AutomationError::from)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AutomationError::Other(e.to_string()))?
|
||||
.map_err(join_error)?
|
||||
}
|
||||
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// The project tree plus the active sequence (null when there is none).
|
||||
async fn document(&self) -> Result<Value, AutomationError> {
|
||||
let project = self.run("project.inspect", json!({})).await?;
|
||||
let sequence = self.run("sequence.inspect", json!({})).await.ok();
|
||||
Ok(json!({"project": project, "sequence": sequence}))
|
||||
}
|
||||
|
||||
fn bridge_client(&self) -> Option<Arc<BridgeClient>> {
|
||||
match &*self.backend {
|
||||
Backend::Bridge(b) => Some(b.clone()),
|
||||
@@ -190,7 +280,9 @@ impl FilmcraftMcp {
|
||||
#[tool_router]
|
||||
impl FilmcraftMcp {
|
||||
#[tool(
|
||||
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids."
|
||||
title = "List commands",
|
||||
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
|
||||
let r = self.run("command.list", json!({})).await.map(|v| {
|
||||
@@ -213,38 +305,99 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History."
|
||||
title = "Run a command",
|
||||
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History.",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.run(&p.id, p.params.unwrap_or(json!({}))).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.")]
|
||||
#[tool(
|
||||
title = "Run several commands",
|
||||
description = "Run several commands in order: {steps: [{id, params?}], stop_on_error?: true}. Returns {completed, failed, results: [{ok, result | error}]}; each edit is its own undo step.",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
|
||||
let stop = p.stop_on_error.unwrap_or(true);
|
||||
let (mut completed, mut failed, mut results) = (0u32, 0u32, Vec::new());
|
||||
for st in p.steps {
|
||||
match self.run(&st.id, st.params.unwrap_or(json!({}))).await {
|
||||
Ok(v) => {
|
||||
completed += 1;
|
||||
results.push(json!({"ok": true, "result": v}));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(json!({"ok": false, "error": e.to_string()}));
|
||||
if stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = json!({"completed": completed, "failed": failed, "results": results});
|
||||
Ok(if failed > 0 { CallToolResult::error(vec![Content::text(v.to_string())]) } else { ok_json(&v) })
|
||||
}
|
||||
|
||||
#[tool(
|
||||
title = "Inspect the document",
|
||||
description = "The project as JSON: the tree (as project_inspect) plus the active sequence (as sequence_inspect; null when there is none).",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn doc_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.document().await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
title = "Render a preview",
|
||||
description = "The active sequence's frame at `seconds` (default: the playhead) as PNG, same as render_frame (bridge mode: Program monitor screenshot).",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn render_preview(&self, p: Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
|
||||
self.render_frame(p).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
title = "Inspect the project tree",
|
||||
description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn project_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.run("project.inspect", json!({})).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second."
|
||||
title = "Inspect the active sequence",
|
||||
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn sequence_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.run("sequence.inspect", json!({})).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).")]
|
||||
#[tool(
|
||||
title = "Import media",
|
||||
description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).",
|
||||
annotations(read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn media_import(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
|
||||
let paths: Vec<&str> = p.text.split('\n').map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
wrap(self.run("file.import", json!({"paths": paths})).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.")]
|
||||
#[tool(
|
||||
title = "Render a frame",
|
||||
description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn render_frame(&self, Parameters(p): Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
|
||||
let max = p.max_side.unwrap_or(960);
|
||||
match &*self.backend {
|
||||
Backend::Headless(s) => {
|
||||
let s = s.clone();
|
||||
let r = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AutomationError> {
|
||||
let mut g = s.lock().map_err(|_| AutomationError::Other("lock".into()))?;
|
||||
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(sec) = p.seconds {
|
||||
g.set_playhead(filmcraft_time::Tick::from_seconds_f64(sec));
|
||||
}
|
||||
@@ -255,7 +408,7 @@ impl FilmcraftMcp {
|
||||
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
.unwrap_or_else(|e| Err(join_error(e)));
|
||||
Ok(match r {
|
||||
Ok(png) => png_result(&png, "rendered frame"),
|
||||
Err(e) => fail(e),
|
||||
@@ -272,17 +425,29 @@ impl FilmcraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).")]
|
||||
#[tool(
|
||||
title = "Live app: UI state",
|
||||
description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.inspect", json!({})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.")]
|
||||
#[tool(
|
||||
title = "Live app: on-screen elements",
|
||||
description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_elements(&self, Parameters(p): Parameters<ElementsParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.elements", json!({"prefix": p.prefix.unwrap_or_default()})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.")]
|
||||
#[tool(
|
||||
title = "Live app: click",
|
||||
description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_click(&self, Parameters(p): Parameters<ClickParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut v = json!({"button": p.button, "count": p.count, "modifiers": p.modifiers});
|
||||
if let Some(id) = p.id {
|
||||
@@ -295,23 +460,37 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks)."
|
||||
title = "Live app: drag",
|
||||
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks).",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_drag(&self, Parameters(p): Parameters<DragParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.drag", json!({"from": p.from, "to": p.to, "steps": p.steps, "modifiers": p.modifiers})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).")]
|
||||
#[tool(
|
||||
title = "Live app: press a key",
|
||||
description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_key(&self, Parameters(p): Parameters<KeyParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.key", json!({"key": p.key})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: type text into the focused field.")]
|
||||
#[tool(
|
||||
title = "Live app: type text",
|
||||
description = "Live app: type text into the focused field.",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_type(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.type", json!({"text": p.text})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: screenshot of the window or one panel (PNG).")]
|
||||
#[tool(
|
||||
title = "Live app: screenshot",
|
||||
description = "Live app: screenshot of the window or one panel (PNG).",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_screenshot(&self, Parameters(p): Parameters<ShotParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => self.screenshot(&b, p.panel, p.max_side.unwrap_or(1600)).await,
|
||||
@@ -320,7 +499,9 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …)."
|
||||
title = "Live app: control-channel call",
|
||||
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …).",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_control(&self, Parameters(p): Parameters<ControlParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui(&p.method, p.params.unwrap_or(json!({}))).await
|
||||
@@ -350,12 +531,104 @@ impl FilmcraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler(
|
||||
router = self.tool_router,
|
||||
name = "filmcraft",
|
||||
instructions = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable). `project_inspect`/`sequence_inspect` return ids you can pass to commands; `render_frame` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`)."
|
||||
)]
|
||||
impl ServerHandler for FilmcraftMcp {}
|
||||
impl FilmcraftMcp {
|
||||
/// The first argument `tool` doesn't take, as a message naming the ones it does.
|
||||
fn unknown_arg(&self, tool: &str, args: Option<&rmcp::model::JsonObject>) -> Option<String> {
|
||||
let def = self.tool_router.get(tool)?;
|
||||
let props = def.input_schema.get("properties").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let bad = args?.keys().find(|k| !props.contains_key(*k))?;
|
||||
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
|
||||
let accepted = if accepted.is_empty() { "no arguments".to_string() } else { accepted.join(", ") };
|
||||
Some(format!("unknown argument \"{bad}\" for {tool}; expected: {accepted}"))
|
||||
}
|
||||
}
|
||||
|
||||
const INSTRUCTIONS: &str = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable; `command_batch` runs several). `doc_inspect` (or `project_inspect`/`sequence_inspect`) returns ids you can pass to commands; `render_preview` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`).";
|
||||
|
||||
/// Resources: the project (as `doc_inspect`) and the command catalog (as `command_list`).
|
||||
const DOCUMENT_URI: &str = "filmcraft://document";
|
||||
const COMMANDS_URI: &str = "filmcraft://commands";
|
||||
|
||||
/// MCP 2026-07-28 clients (current Claude Code) require `ttlMs` and `cacheScope` on list and read
|
||||
/// results; rmcp fills `resultType` and leaves the cache hints to the server, as the generated
|
||||
/// `tools/list` does. The resource list never changes; the document does, so reads aren't cached.
|
||||
fn cache_hints(context: &rmcp::service::RequestContext<rmcp::RoleServer>, ttl_ms: u64) -> Option<(u64, rmcp::model::CacheScope)> {
|
||||
context.protocol_version().is_some_and(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28).then_some((ttl_ms, rmcp::model::CacheScope::Private))
|
||||
}
|
||||
|
||||
#[tool_handler(router = self.tool_router)]
|
||||
impl ServerHandler for FilmcraftMcp {
|
||||
fn get_info(&self) -> rmcp::model::ServerConfig {
|
||||
rmcp::model::ServerConfig::new(rmcp::model::ServerCapabilities::builder().enable_tools().enable_resources().build())
|
||||
.with_server_info(rmcp::model::Implementation::new("filmcraft", env!("CARGO_PKG_VERSION")))
|
||||
.with_instructions(INSTRUCTIONS.to_string())
|
||||
}
|
||||
|
||||
/// Strict arguments and long exports in front of the tool router.
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: rmcp::model::CallToolRequestParams,
|
||||
context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::CallToolResponse, McpError> {
|
||||
if let Some(m) = self.unknown_arg(&request.name, request.arguments.as_ref()) {
|
||||
return Err(McpError::invalid_params(m, None));
|
||||
}
|
||||
// A blocking export reports progress and can be cancelled (docs/agents.md § Long exports).
|
||||
if request.name == "command_run"
|
||||
&& matches!(&*self.backend, Backend::Headless(_))
|
||||
&& let Some(a) = &request.arguments
|
||||
&& let Some(id) = a.get("id").and_then(Value::as_str)
|
||||
{
|
||||
let params = a.get("params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
if crate::long_job::is_long(id, ¶ms) {
|
||||
return Ok(self.run_long(id, params, &context).await.into());
|
||||
}
|
||||
}
|
||||
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
|
||||
self.tool_router.call(tcc).await
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::ListResourcesResult, McpError> {
|
||||
use rmcp::model::Resource;
|
||||
let mut r = rmcp::model::ListResourcesResult::with_all_items(vec![
|
||||
Resource::new(DOCUMENT_URI, "document")
|
||||
.with_title("Project")
|
||||
.with_description("The project tree and the active sequence (same as doc_inspect).")
|
||||
.with_mime_type("application/json"),
|
||||
Resource::new(COMMANDS_URI, "commands")
|
||||
.with_title("Command catalog")
|
||||
.with_description("Every command with id, label, menu path, shortcut, params and enabled state (same as command_list).")
|
||||
.with_mime_type("application/json"),
|
||||
]);
|
||||
if let Some((ttl, scope)) = cache_hints(&context, 600_000) {
|
||||
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
request: rmcp::model::ReadResourceRequestParams,
|
||||
context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::ReadResourceResponse, McpError> {
|
||||
let v = match request.uri.as_str() {
|
||||
DOCUMENT_URI => self.document().await,
|
||||
COMMANDS_URI => self.run("command.list", json!({})).await,
|
||||
other => return Err(McpError::resource_not_found(format!("resource not found: {other}"), None)),
|
||||
}
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
let text = serde_json::to_string_pretty(&v).map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
let mut r = rmcp::model::ReadResourceResult::new(vec![rmcp::model::ResourceContents::text(text, request.uri).with_mime_type("application/json")]);
|
||||
if let Some((ttl, scope)) = cache_hints(&context, 0) {
|
||||
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
|
||||
}
|
||||
Ok(r.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -371,4 +644,249 @@ mod tests {
|
||||
let r = m.render_frame(Parameters(RenderParams { seconds: Some(1.0), max_side: Some(320) })).await.unwrap();
|
||||
assert_ne!(r.is_error, Some(true));
|
||||
}
|
||||
|
||||
/// One request/response exchange over `serve_io`, as an MCP client sees it.
|
||||
struct Client {
|
||||
tx: tokio::io::DuplexStream,
|
||||
rx: tokio::io::Lines<tokio::io::BufReader<tokio::io::DuplexStream>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn start(s: Session) -> Self {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let (tx, server_in) = tokio::io::duplex(1 << 22);
|
||||
let (server_out, rx) = tokio::io::duplex(1 << 22);
|
||||
tokio::spawn(FilmcraftMcp::headless(s).serve_io(server_in, server_out));
|
||||
Self { tx, rx: tokio::io::BufReader::new(rx).lines() }
|
||||
}
|
||||
async fn send(&mut self, line: &str) {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
self.tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
}
|
||||
async fn next(&mut self) -> Value {
|
||||
serde_json::from_str(&self.rx.next_line().await.unwrap().unwrap()).unwrap()
|
||||
}
|
||||
async fn ask(&mut self, msg: Value) -> Value {
|
||||
self.send(&msg.to_string()).await;
|
||||
self.next().await
|
||||
}
|
||||
async fn init(&mut self) -> Value {
|
||||
let r = self.ask(json!({"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}})).await;
|
||||
self.send(&json!({"jsonrpc":"2.0","method":"notifications/initialized"}).to_string()).await;
|
||||
r
|
||||
}
|
||||
async fn call(&mut self, id: u64, name: &str, arguments: Value) -> Value {
|
||||
self.ask(json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":arguments}})).await
|
||||
}
|
||||
}
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Every listed tool has a title and the four hints, and the core tools are listed.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tools_are_titled_and_annotated() {
|
||||
let mut c = Client::start(demo());
|
||||
let init = c.init().await;
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft");
|
||||
let tools = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"})).await["result"]["tools"].as_array().cloned().unwrap();
|
||||
for t in &tools {
|
||||
assert!(t["title"].is_string(), "{} has no title", t["name"]);
|
||||
for h in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
|
||||
assert!(t["annotations"][h].is_boolean(), "{} lacks {h}", t["name"]);
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
for want in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "project_inspect", "render_frame", "ui_click"] {
|
||||
assert!(names.contains(&want), "{want} not listed");
|
||||
}
|
||||
let ro = |n: &str| tools.iter().find(|t| t["name"] == n).map(|t| t["annotations"]["readOnlyHint"].clone());
|
||||
assert_eq!((ro("command_list"), ro("command_run")), (Some(json!(true)), Some(json!(false))));
|
||||
}
|
||||
|
||||
/// Strict arguments, malformed JSON, batch, doc_inspect and render_preview over the raw line
|
||||
/// protocol.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn strict_arguments_and_parse_errors() {
|
||||
let mut c = Client::start(demo());
|
||||
c.init().await;
|
||||
let r = c.call(1, "command_list", json!({"filter": "razor", "enabled_only": true})).await;
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
|
||||
// an unknown argument names itself and the accepted ones
|
||||
let r = c.call(2, "command_list", json!({"filtr": "x"})).await;
|
||||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||||
let m = r["error"]["message"].as_str().unwrap();
|
||||
assert!(m.contains("\"filtr\"") && m.contains("filter") && m.contains("enabled_only"), "{m}");
|
||||
let r = c.call(3, "doc_inspect", json!({"verbose": true})).await;
|
||||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||||
// command params stay free-form
|
||||
let r = c.call(4, "command_run", json!({"id": "playhead.set", "params": {"seconds": 0.5}})).await;
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
// malformed JSON: a parse error, and the server keeps serving
|
||||
c.send("{\"jsonrpc\": \"2.0\", \"id\": 5, BROKEN").await;
|
||||
let r = c.next().await;
|
||||
assert_eq!((r["error"]["code"].as_i64(), r["id"].is_null()), (Some(-32700), true), "{r}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":6,"method":"ping"})).await;
|
||||
assert!(r["result"].is_object(), "{r}");
|
||||
// batch: stops at the first failure and reports both
|
||||
let r =
|
||||
c.call(7, "command_batch", json!({"steps": [{"id": "playhead.set", "params": {"seconds": 0.5}}, {"id": "no.such"}, {"id": "playhead.set"}]})).await;
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!((v["completed"].as_u64(), v["failed"].as_u64(), v["results"].as_array().map(Vec::len)), (Some(1), Some(1), Some(2)), "{v}");
|
||||
let r = c.call(8, "doc_inspect", json!({})).await;
|
||||
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
|
||||
let r = c.call(9, "render_preview", json!({"max_side": 64})).await;
|
||||
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
|
||||
}
|
||||
|
||||
/// `filmcraft://document` and `filmcraft://commands` are listed and readable as JSON.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resources_document_and_commands() {
|
||||
let mut c = Client::start(demo());
|
||||
let init = c.init().await;
|
||||
assert!(init["result"]["capabilities"]["resources"].is_object(), "{init}");
|
||||
assert!(init["result"]["instructions"].as_str().unwrap().contains("doc_inspect"), "{init}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"resources/list"})).await;
|
||||
let uris: Vec<&str> = r["result"]["resources"].as_array().unwrap().iter().filter_map(|x| x["uri"].as_str()).collect();
|
||||
assert_eq!(uris, [DOCUMENT_URI, COMMANDS_URI], "{r}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":DOCUMENT_URI}})).await;
|
||||
assert_eq!(r["result"]["contents"][0]["mimeType"], "application/json", "{r}");
|
||||
let v: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":COMMANDS_URI}})).await;
|
||||
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"filmcraft://nope"}})).await;
|
||||
assert!(r["error"]["code"].is_i64(), "{r}");
|
||||
}
|
||||
|
||||
/// MCP 2026-07-28 clients (current Claude Code) negotiate per request and reject list and read
|
||||
/// results without `resultType`, `ttlMs` and `cacheScope`; sessions that negotiated an older
|
||||
/// revision through `initialize` get the old shape.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn modern_clients_get_result_type_and_cache_hints() {
|
||||
let mut c = Client::start(Session::default());
|
||||
let meta = json!({
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"},
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
});
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":meta}})).await;
|
||||
assert!(r["result"]["supportedVersions"].as_array().unwrap().contains(&json!("2026-07-28")), "{r}");
|
||||
for (id, method, params) in [
|
||||
(2, "tools/list", json!({"_meta": meta})),
|
||||
(3, "resources/list", json!({"_meta": meta})),
|
||||
(4, "resources/read", json!({"_meta": meta, "uri": DOCUMENT_URI})),
|
||||
] {
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
|
||||
assert_eq!(r["result"]["resultType"], "complete", "{method}: {r}");
|
||||
assert!(r["result"]["ttlMs"].is_u64(), "{method}: {r}");
|
||||
assert!(matches!(r["result"]["cacheScope"].as_str(), Some("public" | "private")), "{method}: {r}");
|
||||
}
|
||||
let mut c = Client::start(Session::default());
|
||||
c.init().await;
|
||||
for (id, method, params) in [(2, "tools/list", json!({})), (3, "resources/list", json!({})), (4, "resources/read", json!({"uri": DOCUMENT_URI}))] {
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
|
||||
assert!(r["result"].is_object() && r["result"].get("resultType").is_none() && r["result"].get("ttlMs").is_none(), "{method}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// docs/agents.md § Long exports: a blocking export reports `notifications/progress` for its
|
||||
/// token (none without one), answers `ping` meanwhile, and `notifications/cancelled` stops
|
||||
/// it, deletes the partial file and sends no response.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn export_progress_and_cancel() {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-mcp-progress-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut c = Client::start(demo());
|
||||
c.init().await;
|
||||
let export = |id: u64, name: &str, token: Option<Value>, seconds: f64| {
|
||||
let path = dir.join(name).to_string_lossy().to_string();
|
||||
let params = json!({"path": path, "format": "h264", "width": 640, "height": 360, "audio": false, "range": "custom", "startSeconds": 0, "endSeconds": seconds, "wait": true});
|
||||
let mut call =
|
||||
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"file.exportMedia","params":params}}});
|
||||
if let Some(t) = token {
|
||||
call["params"]["_meta"] = json!({"progressToken": t});
|
||||
}
|
||||
call.to_string()
|
||||
};
|
||||
// no token: no notifications, the same response as before
|
||||
c.send(&export(1, "quiet.mp4", None, 0.5)).await;
|
||||
let m = c.next().await;
|
||||
assert_eq!((m["id"].as_u64(), &m["result"]["isError"]), (Some(1), &json!(false)), "{m}");
|
||||
// progress, strictly increasing, with a total; ping answered while the export runs
|
||||
c.send(&export(2, "a.mp4", Some(json!("tok")), 1.0)).await;
|
||||
let (mut last, mut notes, mut pinged, mut pong) = (-1.0, 0, false, false);
|
||||
let done = loop {
|
||||
let m = c.next().await;
|
||||
if m["method"] == "notifications/progress" {
|
||||
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
|
||||
let p = m["params"]["progress"].as_f64().unwrap();
|
||||
assert!(p > last && m["params"]["total"].as_f64().unwrap() >= p, "{m}");
|
||||
last = p;
|
||||
notes += 1;
|
||||
if !pinged {
|
||||
pinged = true;
|
||||
c.send(r#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#).await;
|
||||
}
|
||||
} else if m["id"] == 3 {
|
||||
pong = true;
|
||||
} else if m["id"] == 2 {
|
||||
break m;
|
||||
}
|
||||
};
|
||||
assert_eq!(done["result"]["isError"], false, "{done}");
|
||||
assert!(notes >= 2 && pong, "{notes} notifications, ping answered during the export: {pong}");
|
||||
assert!(dir.join("a.mp4").exists());
|
||||
// cancel after the first notification: no response, no partial file
|
||||
c.send(&export(4, "b.mp4", Some(json!(7)), 8.0)).await;
|
||||
loop {
|
||||
let m = c.next().await;
|
||||
assert_ne!(m["id"], 4, "finished before the cancel: {m}");
|
||||
if m["method"] == "notifications/progress" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
c.send(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}}"#).await;
|
||||
let t0 = std::time::Instant::now();
|
||||
while dir.join("b.mp4").exists() || t0.elapsed() < std::time::Duration::from_millis(500) {
|
||||
assert!(t0.elapsed().as_secs() < 30, "partial file still there");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
c.send(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"jobs.list"}}}"#).await;
|
||||
let m = loop {
|
||||
let m = c.next().await;
|
||||
assert_ne!(m["id"], 4, "no response for a cancelled request: {m}");
|
||||
if m["id"] == 5 {
|
||||
break m;
|
||||
}
|
||||
};
|
||||
let jobs: Value = serde_json::from_str(m["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
let last = jobs.as_array().and_then(|a| a.last()).cloned().unwrap_or_default();
|
||||
assert_eq!(last["result"]["error"], "cancelled", "{jobs}");
|
||||
assert!(!dir.join("b.mp4").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A command that panicked used to poison the session lock, and every later call failed with
|
||||
/// "session lock poisoned" until the server was restarted.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_poisoned_session_lock_keeps_serving() {
|
||||
let m = FilmcraftMcp::headless(demo());
|
||||
let Backend::Headless(s) = &*m.backend else { panic!("headless backend") };
|
||||
let s = s.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _g = s.lock().unwrap();
|
||||
panic!("a command panicked");
|
||||
})
|
||||
.join();
|
||||
let v = m.run("sequence.inspect", json!({})).await.unwrap();
|
||||
assert!(v.is_object(), "{v}");
|
||||
let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap();
|
||||
assert_ne!(r.is_error, Some(true));
|
||||
}
|
||||
}
|
||||
+70
-15
@@ -184,28 +184,45 @@ struct Timing {
|
||||
tick_rate: i64,
|
||||
}
|
||||
|
||||
fn positive_integer(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse::<i64>().ok().filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
fn parse_ratio(s: &str) -> Option<(i64, i64)> {
|
||||
let mut it = s.split_whitespace().map(|x| x.parse::<i64>().ok());
|
||||
let mut it = s.split_whitespace().map(positive_integer);
|
||||
let a = it.next()??;
|
||||
let b = it.next().flatten().unwrap_or(1);
|
||||
(a > 0 && b > 0).then_some((a, b))
|
||||
let b = it.next()??;
|
||||
it.next().is_none().then_some((a, b))
|
||||
}
|
||||
|
||||
fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
|
||||
n.attributes().find(|a| a.name() == local).map(|a| a.value())
|
||||
}
|
||||
|
||||
fn timing(root: roxmltree::Node) -> Timing {
|
||||
let base = attr(root, "frameRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(30);
|
||||
let (mn, md) = attr(root, "frameRateMultiplier").and_then(parse_ratio).unwrap_or((1, 1));
|
||||
let rate = FrameRate::new(base * mn, md);
|
||||
let sub = attr(root, "subFrameRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(1);
|
||||
let tick_rate = attr(root, "tickRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(if attr(root, "frameRate").is_some() {
|
||||
base * sub
|
||||
} else {
|
||||
1
|
||||
});
|
||||
Timing { rate, base, sub_frame_rate: sub, tick_rate }
|
||||
fn timing(root: roxmltree::Node) -> Result<Timing> {
|
||||
let invalid =
|
||||
|name: &str| Error::Syntax { line: root.document().text_pos_at(root.range().start).row as usize, msg: format!("invalid TTML timing parameter {name}") };
|
||||
let integer = |name: &str, default| match attr(root, name) {
|
||||
Some(v) => positive_integer(v).ok_or_else(|| invalid(name)),
|
||||
None => Ok(default),
|
||||
};
|
||||
let base = integer("frameRate", 30)?;
|
||||
let (mn, md) = match attr(root, "frameRateMultiplier") {
|
||||
Some(v) => parse_ratio(v).ok_or_else(|| invalid("frameRateMultiplier"))?,
|
||||
None => (1, 1),
|
||||
};
|
||||
let rate = FrameRate::new(base.checked_mul(mn).ok_or_else(|| invalid("frameRateMultiplier"))?, md);
|
||||
let sub = integer("subFrameRate", 1)?;
|
||||
let tick_rate = match attr(root, "tickRate") {
|
||||
Some(v) => positive_integer(v).ok_or_else(|| invalid("tickRate"))?,
|
||||
None if attr(root, "frameRate").is_some() => base.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?,
|
||||
None => 1,
|
||||
};
|
||||
Ok(Timing { rate, base, sub_frame_rate: sub, tick_rate })
|
||||
}
|
||||
|
||||
/// Seconds as an exact decimal string → ticks.
|
||||
@@ -383,7 +400,7 @@ pub fn parse(text: &str) -> Result<Document> {
|
||||
if root.tag_name().name() != "tt" {
|
||||
return Err(Error::NotFormat("TTML"));
|
||||
}
|
||||
let tm = timing(root);
|
||||
let tm = timing(root)?;
|
||||
let mut styles = std::collections::HashMap::new();
|
||||
for s in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
|
||||
if let Some(id) = s.attributes().find(|a| a.name() == "id").map(|a| a.value()) {
|
||||
@@ -478,6 +495,44 @@ mod tests {
|
||||
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: 1 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_timing_parameters_return_an_error() {
|
||||
for params in [
|
||||
r#"ttp:frameRate="0""#,
|
||||
r#"ttp:frameRate="-25""#,
|
||||
r#"ttp:frameRate="""#,
|
||||
r#"ttp:subFrameRate="0""#,
|
||||
r#"ttp:tickRate="-1""#,
|
||||
r#"ttp:frameRateMultiplier="1 0""#,
|
||||
r#"ttp:frameRateMultiplier="1""#,
|
||||
r#"ttp:frameRateMultiplier="1 2 3""#,
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="1s" end="2s">Hello</p></div></body></tt>"#
|
||||
);
|
||||
assert!(crate::parse(xml.as_bytes(), crate::Format::Ttml).is_err(), "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_timing_parameters_keep_defaults() {
|
||||
for (params, begin, end) in [
|
||||
("", "1s", "2s"),
|
||||
(r#"ttp:frameRate="25""#, "25f", "50f"),
|
||||
(r#"ttp:frameRate="30" ttp:subFrameRate="2""#, "60t", "120t"),
|
||||
(r#"ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10""#, "10t", "20t"),
|
||||
(r#"ttp:frameRate="25" ttp:frameRateMultiplier="2 1""#, "50f", "100f"),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="{begin}" end="{end}">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = crate::parse(xml.as_bytes(), crate::Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1, "{params}");
|
||||
assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND), "{params}");
|
||||
assert_eq!(doc.cues[0].end, Tick(2 * TICKS_PER_SECOND), "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_expressions() {
|
||||
let t = tm(FrameRate::FPS_25);
|
||||
|
||||
+357
-12
@@ -5,8 +5,11 @@
|
||||
//! for the next frames therefore hit the cache or continue the running decoder without re-seeking.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, TryLockError, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
use filmcraft_color::ColorInfo;
|
||||
use filmcraft_frame::VideoFrame;
|
||||
@@ -68,7 +71,7 @@ fn count_frames<T>(out: &[T]) {
|
||||
|
||||
/// Run a decoder call, adding its wall time to the process-wide counter.
|
||||
fn timed<R>(f: impl FnOnce() -> R) -> R {
|
||||
let t0 = web_time::Instant::now();
|
||||
let t0 = Instant::now();
|
||||
let r = f();
|
||||
DECODE_NS.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
|
||||
r
|
||||
@@ -135,9 +138,35 @@ struct State {
|
||||
/// The cached frames that were decoded in draft mode: served to draft requests only.
|
||||
drafts: std::collections::BTreeSet<i64>,
|
||||
bytes: usize,
|
||||
/// When a frame was last asked of this cache.
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Give up the decoder (and the spare intra decoders): the next request that has to decode
|
||||
/// makes a new one and restarts at a sync sample. Returned so they are dropped off the lock.
|
||||
fn release_decoder(&mut self, pool: &Pool) -> Vec<Box<dyn VideoDecoder>> {
|
||||
let mut out = std::mem::take(&mut self.spare);
|
||||
if let Some(d) = self.decoder.take() {
|
||||
pool.decoders.fetch_sub(1, Ordering::Relaxed);
|
||||
out.push(d);
|
||||
}
|
||||
self.next = usize::MAX;
|
||||
out
|
||||
}
|
||||
|
||||
/// Evict frames, earliest first, while `more` says so.
|
||||
fn release_frames(&mut self, pool: &Pool, more: impl Fn() -> bool) {
|
||||
while more() {
|
||||
let Some((pts, f)) = self.frames.pop_first() else { break };
|
||||
self.drafts.remove(&pts);
|
||||
self.bytes = self.bytes.saturating_sub(f.byte_size());
|
||||
pool.bytes.fetch_sub(f.byte_size(), Ordering::Relaxed);
|
||||
EVICTED.fetch_add(1, Ordering::Relaxed);
|
||||
filmcraft_frame::pool::recycle(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached frame at `pts`, unless it is a draft frame and the request wants the exact one.
|
||||
fn cached(&self, pts: i64, draft_ok: bool) -> Option<&Arc<VideoFrame>> {
|
||||
self.frames.get(&pts).filter(|_| draft_ok || !self.drafts.contains(&pts))
|
||||
@@ -178,9 +207,140 @@ impl Drop for DecodingGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoder keeps its reference and in-flight pictures (hundreds of MB for a frame-threaded
|
||||
/// 4K decoder), so only this many caches keep theirs once idle: a timeline's other clips make a
|
||||
/// new one when the playhead reaches them, which costs what a seek costs.
|
||||
const MAX_LIVE_DECODERS: usize = 4;
|
||||
|
||||
/// A cache asked for a frame this recently is in use (a layer of the frame being composited,
|
||||
/// the next clip being prefetched): it keeps its decoder whatever the count.
|
||||
const IDLE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Decoded frames all caches may hold together before idle ones give theirs up. Each cache also
|
||||
/// has its own budget, which alone let every clip on a timeline keep hundreds of MB of frames
|
||||
/// nobody was looking at.
|
||||
pub const FRAME_BUDGET: usize = 1 << 30;
|
||||
|
||||
/// What the caches of a process share: the cap on live decoders and the frame budget.
|
||||
struct Pool {
|
||||
caches: Mutex<Vec<Weak<Shared>>>,
|
||||
/// Caches that hold a decoder.
|
||||
decoders: AtomicUsize,
|
||||
max_decoders: usize,
|
||||
idle: Duration,
|
||||
/// Bytes of the frames the caches hold.
|
||||
bytes: AtomicUsize,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
/// The part of a [`GopCache`] its pool reaches.
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
pool: Arc<Pool>,
|
||||
}
|
||||
|
||||
impl Drop for Shared {
|
||||
fn drop(&mut self) {
|
||||
let st = self.state.get_mut().unwrap_or_else(PoisonError::into_inner);
|
||||
drop(st.release_decoder(&self.pool));
|
||||
self.pool.bytes.fetch_sub(st.bytes, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache's state unless another thread is using it (it is then decoding: not idle).
|
||||
fn try_state(c: &Shared) -> Option<MutexGuard<'_, State>> {
|
||||
match c.state.try_lock() {
|
||||
Ok(g) => Some(g),
|
||||
Err(TryLockError::Poisoned(e)) => Some(e.into_inner()),
|
||||
Err(TryLockError::WouldBlock) => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
fn new(max_decoders: usize, idle: Duration, budget: usize) -> Self {
|
||||
Self { caches: Mutex::new(Vec::new()), decoders: AtomicUsize::new(0), max_decoders, idle, bytes: AtomicUsize::new(0), budget }
|
||||
}
|
||||
|
||||
/// The pool of every cache in the process.
|
||||
fn global() -> Arc<Pool> {
|
||||
static POOL: std::sync::OnceLock<Arc<Pool>> = std::sync::OnceLock::new();
|
||||
POOL.get_or_init(|| Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET))).clone()
|
||||
}
|
||||
|
||||
fn register(&self, c: &Arc<Shared>) {
|
||||
let mut g = self.caches.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.retain(|w| w.strong_count() > 0);
|
||||
g.push(Arc::downgrade(c));
|
||||
}
|
||||
|
||||
/// The idle caches other than `me` that `keep` selects, least recently used first. Other
|
||||
/// caches are only ever try-locked, so two caches trimming each other cannot deadlock.
|
||||
fn idle_others(&self, me: &Shared, keep: impl Fn(&State) -> bool) -> Vec<Arc<Shared>> {
|
||||
let all: Vec<Arc<Shared>> = self.caches.lock().unwrap_or_else(PoisonError::into_inner).iter().filter_map(Weak::upgrade).collect();
|
||||
let mut idle: Vec<(Instant, Arc<Shared>)> = all
|
||||
.into_iter()
|
||||
.filter(|c| !std::ptr::eq(Arc::as_ptr(c), me))
|
||||
.filter_map(|c| {
|
||||
let used = try_state(&c).filter(|st| keep(st) && st.last_used.elapsed() >= self.idle).map(|st| st.last_used)?;
|
||||
Some((used, c))
|
||||
})
|
||||
.collect();
|
||||
idle.sort_by_key(|(used, _)| *used);
|
||||
idle.into_iter().map(|(_, c)| c).collect()
|
||||
}
|
||||
|
||||
/// Take the decoders of idle caches, least recently used first, down to the cap. The caller
|
||||
/// drops them with no cache lock held: dropping a decoder can block (a hardware session waits
|
||||
/// for its frames in flight).
|
||||
#[must_use = "drop the released decoders with no cache lock held"]
|
||||
fn trim_decoders(&self, me: &Shared) -> Vec<Box<dyn VideoDecoder>> {
|
||||
let mut released = Vec::new();
|
||||
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
|
||||
return released;
|
||||
}
|
||||
for c in self.idle_others(me, |st| st.decoder.is_some()) {
|
||||
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
|
||||
break;
|
||||
}
|
||||
if let Some(mut st) = try_state(&c) {
|
||||
released.extend(st.release_decoder(self));
|
||||
}
|
||||
}
|
||||
released
|
||||
}
|
||||
|
||||
/// Evict the frames of idle caches, least recently used first, down to the budget. Caches in
|
||||
/// use keep theirs (each within its own budget): a frame evicted before it is shown costs a
|
||||
/// re-decode from the keyframe.
|
||||
fn trim_frames(&self, me: &Shared) {
|
||||
let over = || self.bytes.load(Ordering::Relaxed) > self.budget;
|
||||
if !over() {
|
||||
return;
|
||||
}
|
||||
for c in self.idle_others(me, |st| !st.frames.is_empty()) {
|
||||
if !over() {
|
||||
break;
|
||||
}
|
||||
if let Some(mut st) = try_state(&c) {
|
||||
st.release_frames(self, over);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Caches that hold a decoder right now, process-wide (`perf.stats`).
|
||||
pub fn live_decoders() -> usize {
|
||||
Pool::global().decoders.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Bytes of decoded frames the caches hold right now, process-wide (`perf.stats`).
|
||||
pub fn cached_bytes() -> usize {
|
||||
Pool::global().bytes.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Decoder + decoded-frame cache for one video track.
|
||||
pub struct GopCache {
|
||||
state: Mutex<State>,
|
||||
shared: Arc<Shared>,
|
||||
/// Colour signalled by the container, which wins over the bitstream's.
|
||||
explicit_color: Option<ColorInfo>,
|
||||
/// Clockwise quarter turns from the container's display matrix, applied to every frame.
|
||||
@@ -200,7 +360,13 @@ const CONTINUE_THROUGH: usize = 48;
|
||||
|
||||
impl GopCache {
|
||||
pub fn new(explicit_color: Option<ColorInfo>) -> Self {
|
||||
Self {
|
||||
// unit tests count decoder restarts: each of their caches gets a pool of its own
|
||||
let pool = if cfg!(test) { Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET)) } else { Pool::global() };
|
||||
Self::in_pool(explicit_color, pool)
|
||||
}
|
||||
|
||||
fn in_pool(explicit_color: Option<ColorInfo>, pool: Arc<Pool>) -> Self {
|
||||
let shared = Arc::new(Shared {
|
||||
state: Mutex::new(State {
|
||||
decoder: None,
|
||||
intra: false,
|
||||
@@ -211,11 +377,12 @@ impl GopCache {
|
||||
frames: BTreeMap::new(),
|
||||
drafts: Default::default(),
|
||||
bytes: 0,
|
||||
last_used: Instant::now(),
|
||||
}),
|
||||
explicit_color,
|
||||
rotation: 0,
|
||||
budget: 384 << 20,
|
||||
}
|
||||
pool: pool.clone(),
|
||||
});
|
||||
pool.register(&shared);
|
||||
Self { shared, explicit_color, rotation: 0, budget: 384 << 20 }
|
||||
}
|
||||
|
||||
/// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display
|
||||
@@ -245,9 +412,11 @@ impl GopCache {
|
||||
st.drafts.remove(&pts);
|
||||
}
|
||||
let budget = self.budget.max(MIN_FRAMES * f.byte_size());
|
||||
let before = st.bytes;
|
||||
st.bytes += f.byte_size();
|
||||
if let Some(old) = st.frames.insert(pts, Arc::new(f)) {
|
||||
st.bytes -= old.byte_size();
|
||||
filmcraft_frame::pool::recycle(old);
|
||||
}
|
||||
// evict frames far from the most recent (keep a window around the working position)
|
||||
while st.bytes > budget && st.frames.len() > 2 {
|
||||
@@ -257,8 +426,17 @@ impl GopCache {
|
||||
st.drafts.remove(&victim);
|
||||
st.bytes -= v.byte_size();
|
||||
EVICTED.fetch_add(1, Ordering::Relaxed);
|
||||
// its planes serve the next decoded pictures instead of going back to the allocator
|
||||
filmcraft_frame::pool::recycle(v);
|
||||
}
|
||||
}
|
||||
let pool = &self.shared.pool;
|
||||
if st.bytes >= before {
|
||||
pool.bytes.fetch_add(st.bytes - before, Ordering::Relaxed);
|
||||
} else {
|
||||
pool.bytes.fetch_sub(before - st.bytes, Ordering::Relaxed);
|
||||
}
|
||||
pool.trim_frames(&self.shared);
|
||||
}
|
||||
|
||||
/// Store decoder output (in presentation order) and advance `out_max`.
|
||||
@@ -297,8 +475,13 @@ impl GopCache {
|
||||
return self.private_frame(s, i, want_pts, n);
|
||||
}
|
||||
let draft = filmcraft_media::cancel::draft();
|
||||
let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Idle caches' decoders released below. Declared before the lock guard, so it is dropped
|
||||
// after the guard on every return path: dropping a decoder can block (a hardware session
|
||||
// waits for its frames in flight) and must not hold this cache's lock.
|
||||
let mut released: Vec<Box<dyn VideoDecoder>> = Vec::new();
|
||||
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _decoding = DecodingGuard::enter(me);
|
||||
st.last_used = Instant::now();
|
||||
if let Some(f) = st.cached(want_pts, draft) {
|
||||
HITS.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(f.clone());
|
||||
@@ -309,7 +492,9 @@ impl GopCache {
|
||||
st.intra = d.intra_only();
|
||||
st.decoder = Some(d);
|
||||
st.next = usize::MAX;
|
||||
self.shared.pool.decoders.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
released.extend(self.shared.pool.trim_decoders(&self.shared));
|
||||
// Nothing cached: the work ahead (possibly a seek and a GOP of decoding) is only worth it
|
||||
// while someone still wants the frame.
|
||||
if filmcraft_media::cancel::cancelled() {
|
||||
@@ -462,7 +647,7 @@ impl GopCache {
|
||||
DECODED.fetch_add(1, Ordering::Relaxed);
|
||||
timed(|| dec.decode(&data, want_pts))
|
||||
});
|
||||
let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if st.spare.len() < 16 {
|
||||
st.spare.push(dec);
|
||||
}
|
||||
@@ -480,7 +665,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::video::DecodedFrame;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// `n` samples, a sync sample every `gop`, pts = 1000 · index (no reordering).
|
||||
struct Samples {
|
||||
@@ -716,4 +900,165 @@ mod tests {
|
||||
assert_eq!(index_of(&c.frame(&s, 7_000).expect("frame")), 7);
|
||||
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_caches_give_up_their_decoders_beyond_the_cap() {
|
||||
let pool = Arc::new(Pool::new(2, Duration::ZERO, FRAME_BUDGET));
|
||||
let live = || pool.decoders.load(Ordering::Relaxed);
|
||||
let s: Vec<Samples> = (0..5).map(|_| samples(300, 250, 3, false)).collect();
|
||||
let c: Vec<GopCache> = (0..5).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
// a clip after another, as when clips are dropped on a timeline
|
||||
for k in 0..5 {
|
||||
assert_eq!(index_of(&c[k].frame(&s[k], 10_000).expect("frame")), 10);
|
||||
assert!(live() <= 2, "{} live decoders after clip {k}", live());
|
||||
}
|
||||
assert_eq!(live(), 2);
|
||||
// the most recently used keep theirs and carry on without a seek
|
||||
assert_eq!(index_of(&c[4].frame(&s[4], 20_000).expect("frame")), 20);
|
||||
assert_eq!(s[4].resets.load(Ordering::Relaxed), 1);
|
||||
// a cache that lost its decoder still serves what it cached…
|
||||
let decodes = s[0].decodes.load(Ordering::Relaxed);
|
||||
assert_eq!(index_of(&c[0].frame(&s[0], 10_000).expect("cached")), 10);
|
||||
assert_eq!(s[0].decodes.load(Ordering::Relaxed), decodes);
|
||||
// …and decodes again with a new decoder, from the sync sample
|
||||
assert_eq!(index_of(&c[0].frame(&s[0], 20_000).expect("frame")), 20);
|
||||
assert_eq!(s[0].resets.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(live(), 2);
|
||||
// a dropped cache no longer counts
|
||||
drop(c);
|
||||
assert_eq!(live(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caches_in_use_keep_their_decoders_whatever_the_cap() {
|
||||
// every layer of one composited frame was asked moments ago: none is idle
|
||||
let pool = Arc::new(Pool::new(2, Duration::from_secs(3600), FRAME_BUDGET));
|
||||
let s: Vec<Samples> = (0..5).map(|_| samples(300, 250, 3, false)).collect();
|
||||
let c: Vec<GopCache> = (0..5).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
for t in [10_000, 11_000, 12_000] {
|
||||
for k in 0..5 {
|
||||
assert_eq!(index_of(&c[k].frame(&s[k], t).expect("frame")) as i64, t / 1000);
|
||||
}
|
||||
}
|
||||
assert_eq!(pool.decoders.load(Ordering::Relaxed), 5);
|
||||
assert!(s.iter().all(|s| s.resets.load(Ordering::Relaxed) == 1), "no decoder restarted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intra_caches_give_up_their_spare_decoders_too() {
|
||||
let pool = Arc::new(Pool::new(1, Duration::ZERO, FRAME_BUDGET));
|
||||
let (a, b) = (samples(50, 1, 0, true), samples(50, 1, 0, true));
|
||||
let (ca, cb) = (GopCache::in_pool(None, pool.clone()), GopCache::in_pool(None, pool.clone()));
|
||||
assert_eq!(index_of(&ca.frame(&a, 3_000).expect("frame")), 3);
|
||||
assert_eq!(index_of(&cb.frame(&b, 4_000).expect("frame")), 4);
|
||||
let st = ca.shared.state.lock().unwrap();
|
||||
assert!(st.decoder.is_none() && st.spare.is_empty());
|
||||
drop(st);
|
||||
assert_eq!(pool.decoders.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(index_of(&ca.frame(&a, 5_000).expect("frame")), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_caches_give_up_their_frames_beyond_the_shared_budget() {
|
||||
// 1×1 RGBA pictures: 4 bytes each, a shared budget of ten of them
|
||||
let pool = Arc::new(Pool::new(usize::MAX, Duration::ZERO, 40));
|
||||
let held = || pool.bytes.load(Ordering::Relaxed);
|
||||
let (a, b) = (samples(300, 250, 0, false), samples(300, 250, 0, false));
|
||||
let (ca, cb) = (GopCache::in_pool(None, pool.clone()), GopCache::in_pool(None, pool.clone()));
|
||||
// the only cache keeps what its own budget allows
|
||||
assert_eq!(index_of(&ca.frame(&a, 10_000).expect("frame")), 10);
|
||||
assert_eq!(held(), 44);
|
||||
// a second clip takes the room from the idle first one, earliest frames first
|
||||
assert_eq!(index_of(&cb.frame(&b, 5_000).expect("frame")), 5);
|
||||
assert_eq!(held(), 40);
|
||||
let decodes = b.decodes.load(Ordering::Relaxed);
|
||||
assert!((0..=5).all(|i| index_of(&cb.frame(&b, i * 1000).expect("cached")) as i64 == i));
|
||||
assert_eq!(b.decodes.load(Ordering::Relaxed), decodes, "the clip in use lost nothing");
|
||||
let st = ca.shared.state.lock().unwrap();
|
||||
assert_eq!((st.frames.len(), st.bytes), (4, 16));
|
||||
assert_eq!(st.frames.keys().next(), Some(&7_000));
|
||||
drop(st);
|
||||
// an evicted frame decodes again
|
||||
assert_eq!(index_of(&ca.frame(&a, 2_000).expect("frame")), 2);
|
||||
assert!(held() <= 40 + 4 * 3, "{} bytes held", held());
|
||||
drop((ca, cb));
|
||||
assert_eq!(held(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caches_in_use_keep_their_frames_whatever_the_shared_budget() {
|
||||
let pool = Arc::new(Pool::new(usize::MAX, Duration::from_secs(3600), 40));
|
||||
let (a, b) = (samples(300, 250, 0, false), samples(300, 250, 0, false));
|
||||
let (ca, cb) = (GopCache::in_pool(None, pool.clone()), GopCache::in_pool(None, pool.clone()));
|
||||
assert_eq!(index_of(&ca.frame(&a, 10_000).expect("frame")), 10);
|
||||
assert_eq!(index_of(&cb.frame(&b, 10_000).expect("frame")), 10);
|
||||
assert_eq!(pool.bytes.load(Ordering::Relaxed), 88);
|
||||
let decodes = a.decodes.load(Ordering::Relaxed);
|
||||
assert_eq!(index_of(&ca.frame(&a, 0).expect("cached")), 0);
|
||||
assert_eq!(a.decodes.load(Ordering::Relaxed), decodes);
|
||||
}
|
||||
|
||||
/// Pictures big enough for the plane pool (its own size, so that parallel tests cannot take
|
||||
/// the buffers), no reordering, one long GOP.
|
||||
struct Big(usize);
|
||||
const BIG: (u32, u32) = (200, 101);
|
||||
|
||||
impl VideoDecoder for Big {
|
||||
fn decode(&mut self, _sample: &[u8], pts: i64) -> crate::Result<Vec<DecodedFrame>> {
|
||||
let mut px = filmcraft_frame::pool::take_u8((BIG.0 * BIG.1 * 4) as usize);
|
||||
px.resize((BIG.0 * BIG.1 * 4) as usize, (pts / 1000) as u8);
|
||||
Ok(vec![DecodedFrame { pts, frame: VideoFrame::rgba8(BIG.0, BIG.1, px), draft: false }])
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
Vec::new()
|
||||
}
|
||||
fn reset(&mut self) {}
|
||||
fn name(&self) -> &str {
|
||||
"big"
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoSamples for Big {
|
||||
fn count(&self) -> usize {
|
||||
self.0
|
||||
}
|
||||
fn pts(&self, i: usize) -> i64 {
|
||||
i as i64 * 1000
|
||||
}
|
||||
fn sync_before(&self, _i: usize) -> usize {
|
||||
0
|
||||
}
|
||||
fn sample_at(&self, t: i64) -> Option<usize> {
|
||||
let i = (t / 1000) as usize;
|
||||
(i < self.0).then_some(i)
|
||||
}
|
||||
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
|
||||
Ok(vec![i as u8])
|
||||
}
|
||||
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
|
||||
Ok(Box::new(Big(self.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicted_frames_are_decoded_into_again() {
|
||||
let s = Big(200);
|
||||
// room for MIN_FRAMES pictures only
|
||||
let mut c = GopCache::new(None);
|
||||
c.budget = 0;
|
||||
let reused = || filmcraft_frame::pool::stats().reused;
|
||||
let before = reused();
|
||||
// the cache holds MIN_FRAMES pictures: none is evicted yet, every plane is new
|
||||
for i in 0..MIN_FRAMES as i64 {
|
||||
assert_eq!(c.frame(&s, i * 1000).expect("frame").width, BIG.0);
|
||||
}
|
||||
assert_eq!(reused(), before);
|
||||
// from here on each picture evicts one, whose buffer the next picture is decoded into
|
||||
let more = 40;
|
||||
for i in MIN_FRAMES as i64..MIN_FRAMES as i64 + more {
|
||||
let f = c.frame(&s, i * 1000).expect("frame");
|
||||
assert!(matches!(&f.data, filmcraft_frame::PixelData::Rgba8(d) if d.iter().all(|&b| b == i as u8)), "frame {i} has its own pixels");
|
||||
}
|
||||
assert!(reused() - before >= more as u64 - 2, "{} of {more} planes recycled", reused() - before);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ pub mod video;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
pub use audio::AudioFileSource;
|
||||
pub use gop::{GopStats, gop_stats};
|
||||
pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders};
|
||||
pub use mkv::MkvSource;
|
||||
pub use mp4::Mp4Source;
|
||||
pub use mpeg::MpegSource;
|
||||
@@ -89,6 +89,11 @@ pub fn register_video_decoder(f: VideoDecoderFactory) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `f` is among the registered video decoder factories (startup diagnostics, tests).
|
||||
pub fn video_decoder_registered(f: VideoDecoderFactory) -> bool {
|
||||
factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
|
||||
}
|
||||
|
||||
/// Create a decoder for a sample entry.
|
||||
pub fn make_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<Box<dyn VideoDecoder>> {
|
||||
let g = factories().read().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -359,7 +359,8 @@ impl MediaSource for Mp4Source {
|
||||
let ch = ainfo.channels.max(1) as usize;
|
||||
// Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset).
|
||||
let ratio = src_rate as f64 / sample_rate as f64;
|
||||
let s0 = (start as f64 * ratio).floor() as i64 - self.audio_offset * src_rate as i64 / track.timescale.max(1) as i64;
|
||||
let p0 = (start as f64 * ratio).floor() as i64;
|
||||
let s0 = p0 - self.audio_offset * src_rate as i64 / track.timescale.max(1) as i64;
|
||||
let need = (frames as f64 * ratio).ceil() as i64 + 2;
|
||||
// Samples-per-unit: the track timescale is usually the sample rate for audio.
|
||||
let unit = src_rate as f64 / track.timescale.max(1) as f64;
|
||||
@@ -393,8 +394,9 @@ impl MediaSource for Mp4Source {
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
// resample the local window
|
||||
let frac = s0 as f64 - start as f64 * ratio;
|
||||
// Resample the local window. Its first sample is presentation sample `p0`; the edit list
|
||||
// offset is already in `s0` and must not shift the read position a second time.
|
||||
let frac = p0 as f64 - start as f64 * ratio;
|
||||
let mut out = AudioBuffer::silence(sample_rate, ch, frames);
|
||||
for k in 0..frames {
|
||||
let pos = k as f64 * ratio - frac;
|
||||
|
||||
@@ -204,12 +204,14 @@ pub struct H264Decoder {
|
||||
|
||||
impl H264Decoder {
|
||||
pub fn new(avcc: Vec<u8>) -> Result<Self> {
|
||||
recycle_h264_planes();
|
||||
let dec = filmcraft_h264::Decoder::from_avcc(&avcc).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let length_size = avcc_length_size(&avcc);
|
||||
Ok(Self { avcc, dec, length_size, draft: false })
|
||||
}
|
||||
/// A decoder for Annex B byte-stream samples (start codes, in-band parameter sets: MXF, TS).
|
||||
pub fn annexb() -> Self {
|
||||
recycle_h264_planes();
|
||||
Self { avcc: Vec::new(), dec: filmcraft_h264::Decoder::new(), length_size: 0, draft: false }
|
||||
}
|
||||
fn convert(p: filmcraft_h264::Picture) -> DecodedFrame {
|
||||
@@ -269,6 +271,12 @@ impl VideoDecoder for H264Decoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// The H.264 decoder's output planes come from the frames the caches evicted
|
||||
/// (`filmcraft_frame::pool`) instead of the allocator.
|
||||
fn recycle_h264_planes() {
|
||||
filmcraft_h264::set_plane_allocator(filmcraft_frame::pool::take_u8);
|
||||
}
|
||||
|
||||
/// Worker threads each H.264 decoder uses (frame threading; 1 on wasm).
|
||||
pub fn h264_threads() -> usize {
|
||||
filmcraft_h264::default_threads()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! MP4 audio against ffmpeg: ffmpeg writes an AAC track (with the edit list that skips the encoder
|
||||
//! priming), and reads at another sample rate must land on the same source samples as the native
|
||||
//! decode, however the requests are cut.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
/// A source whose rate differs from the sequence's is resampled per request. An AAC track in MP4
|
||||
/// carries an edit list that skips the encoder priming (ffmpeg: 1024 samples), and every request
|
||||
/// must still land on the same source samples: reading at 44.1 kHz in video-frame-sized pieces (as
|
||||
/// an export does) equals the 48 kHz decode interpolated at the same times.
|
||||
#[test]
|
||||
fn aac_mp4_resampled_reads_follow_the_edit_list() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let Some(f) = fixture(&ff, "tone_aac_48k.mp4", &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=3", "-c:a", "aac", "-b:a", "128k"]) else {
|
||||
panic!("could not write the AAC fixture")
|
||||
};
|
||||
let src = filmcraft_codecs::open_bytes("tone_aac_48k.mp4", bytes(&f)).unwrap();
|
||||
let native = src.audio(0, 2 * 48_000, 48_000).unwrap().channels[0].clone();
|
||||
let ratio = 48_000.0 / 44_100.0;
|
||||
// 30 fps at 44.1 kHz, starting mid-file and off the 48 kHz grid
|
||||
let (mut start, piece) = (22_051i64, 1470usize);
|
||||
let (mut worst, mut peak) = (0f32, 0f32);
|
||||
for _ in 0..20 {
|
||||
let got = src.audio(start, piece, 44_100).unwrap();
|
||||
for (k, &v) in got.channels[0].iter().enumerate() {
|
||||
let pos = (start + k as i64) as f64 * ratio;
|
||||
let i0 = pos.floor() as usize;
|
||||
let want = native[i0] + (native[i0 + 1] - native[i0]) * (pos - i0 as f64) as f32;
|
||||
worst = worst.max((v - want).abs());
|
||||
peak = peak.max(v.abs());
|
||||
}
|
||||
start += piece as i64;
|
||||
}
|
||||
let level = native.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!(peak <= level * 1.01, "resampled peak {peak} above the source's {level}");
|
||||
assert!(worst < 1e-4, "resampled read differs from the source by {worst}");
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Decoded pictures are written into recycled plane buffers (`filmcraft_frame::pool`). A recycled
|
||||
//! buffer still holds an old picture, so the decoder must overwrite every byte: here the pool is
|
||||
//! primed with buffers full of 0xAA before decoding, and every frame must still match ffmpeg bit
|
||||
//! for bit. (Its own process: the pool is global.)
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::*;
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||||
use filmcraft_media::FrameRequest;
|
||||
|
||||
#[test]
|
||||
fn dirty_recycled_planes_decode_bit_exact() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
// 640×480: luma and chroma planes are both large enough to be pooled
|
||||
let Some(file) = fixture(
|
||||
&ff,
|
||||
"pool_h264.mp4",
|
||||
&[
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=640x480:r=24:d=1,noise=alls=10:allf=t",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-x264-params",
|
||||
"bframes=2:keyint=12",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
],
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (640usize, 480usize);
|
||||
let raw = ffmpeg_frames(&ff, &file, "yuv420p");
|
||||
let fsize = w * h * 3 / 2;
|
||||
let n = raw.len() / fsize;
|
||||
assert!(n >= 20, "{n} frames");
|
||||
let reference = |i: usize| raw_planes(&raw[i * fsize..(i + 1) * fsize], w, h, w / 2, h / 2, 1);
|
||||
|
||||
// prime the pool with dirty buffers of exactly the plane sizes the decoder asks for
|
||||
for _ in 0..8 {
|
||||
let dirty = |len: usize| Arc::new(vec![0xAAu8; len]);
|
||||
let f = VideoFrame {
|
||||
data: PixelData::Yuv8 { planes: [dirty(w * h), dirty(w * h / 4), dirty(w * h / 4)], chroma: Chroma::C420, alpha: None },
|
||||
..VideoFrame::rgba8(w as u32, h as u32, vec![0; w * h * 4])
|
||||
};
|
||||
filmcraft_frame::pool::recycle(Arc::new(f));
|
||||
}
|
||||
let reused_before = filmcraft_frame::pool::stats().reused;
|
||||
|
||||
let src = filmcraft_codecs::open_bytes("pool_h264.mp4", bytes(&file)).expect("open");
|
||||
let rate = src.info().frame_rate();
|
||||
for i in 0..n {
|
||||
let f = src.video_frame(FrameRequest::full(rate.tick_of(i as i64))).expect("frame");
|
||||
assert_eq!(max_diff(&planes(&f), &reference(i)), 0, "frame {i} decoded into a recycled buffer");
|
||||
}
|
||||
assert!(filmcraft_frame::pool::stats().reused > reused_before, "the decoder took the dirty pooled buffers");
|
||||
}
|
||||
+153
-51
@@ -351,21 +351,66 @@ pub fn delete_items(seq: &mut Sequence, items: &[ClipId]) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// Ripple delete track items: remove them and close the resulting gaps.
|
||||
pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<()> {
|
||||
// Collect per-track ranges to close (merged), processed right-to-left.
|
||||
let mut ranges: Vec<(TrackId, TimeRange)> = Vec::new();
|
||||
/// The stretches a ripple delete of `items` closes, in time order: the time the deleted items
|
||||
/// covered, less whatever stays on the tracks that lose an item. Every such track then moves up
|
||||
/// by the same amount, into time that is free on all of them, so later clips stay in sync and
|
||||
/// nothing is overwritten. A clip and its linked sound with a split edit have different edges;
|
||||
/// closing each one's own range would move the tracks twice. Locked tracks lose nothing and do
|
||||
/// not count.
|
||||
fn ripple_delete_spans(seq: &Sequence, items: &[ClipId]) -> Vec<TimeRange> {
|
||||
let (mut deleted, mut kept) = (Vec::new(), Vec::new());
|
||||
for tr in seq.all_tracks().filter(|t| !t.locked && t.items.iter().any(|i| items.contains(&i.id))) {
|
||||
for i in &tr.items {
|
||||
if items.contains(&i.id) { deleted.push(i.range()) } else { kept.push(i.range()) }
|
||||
}
|
||||
}
|
||||
let kept = merged(kept);
|
||||
let mut spans = Vec::new();
|
||||
for r in merged(deleted) {
|
||||
let mut at = r.start;
|
||||
for k in kept.iter().filter(|k| k.overlaps(&r)) {
|
||||
if k.start > at {
|
||||
spans.push(TimeRange::from_bounds(at, k.start));
|
||||
}
|
||||
at = at.max(k.end());
|
||||
}
|
||||
if at < r.end() {
|
||||
spans.push(TimeRange::from_bounds(at, r.end()));
|
||||
}
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// `ranges` in time order, with the ones that touch or overlap joined.
|
||||
fn merged(mut ranges: Vec<TimeRange>) -> Vec<TimeRange> {
|
||||
ranges.sort_by_key(|r| r.start);
|
||||
let mut out: Vec<TimeRange> = Vec::new();
|
||||
for r in ranges {
|
||||
match out.last_mut() {
|
||||
Some(last) if r.start <= last.end() => *last = TimeRange::from_bounds(last.start, last.end().max(r.end())),
|
||||
_ => out.push(r),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ripple delete track items: remove them and close the resulting gaps. Returns the stretches
|
||||
/// that were closed, in time order. Refused when no gap can close (what stays on the items'
|
||||
/// tracks covers all of their time): that would be a plain delete.
|
||||
pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<Vec<TimeRange>> {
|
||||
// Collect the tracks that lose an item; the gaps close right-to-left.
|
||||
let mut affected: Vec<TrackId> = Vec::new();
|
||||
for c in items {
|
||||
let (tid, it) = seq.find_item(*c).ok_or(EditError::NoItem(*c))?;
|
||||
ranges.push((tid, it.range()));
|
||||
let (tid, _) = seq.find_item(*c).ok_or(EditError::NoItem(*c))?;
|
||||
affected.push(tid);
|
||||
}
|
||||
let spans = ripple_delete_spans(seq, items);
|
||||
if spans.is_empty() && !items.is_empty() {
|
||||
let locked = affected.iter().all(|t| seq.track(*t).is_some_and(|tr| tr.locked));
|
||||
return Err(if locked { EditError::Locked } else { EditError::Other("no gap can close: other clips on these tracks cover the deleted time".into()) });
|
||||
}
|
||||
// Group identical ranges across tracks (linked A/V) into one ripple.
|
||||
let mut spans: Vec<TimeRange> = ranges.iter().map(|r| r.1).collect();
|
||||
spans.sort_by_key(|r| (r.start, r.duration));
|
||||
spans.dedup();
|
||||
let mut work = seq.clone();
|
||||
delete_items(&mut work, items);
|
||||
let affected: Vec<TrackId> = ranges.iter().map(|r| r.0).collect();
|
||||
for span in spans.iter().rev() {
|
||||
// the gap actually closable: from span.start to the next material on affected tracks
|
||||
for tr in work.all_tracks_mut() {
|
||||
@@ -383,7 +428,7 @@ pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
*seq = work;
|
||||
Ok(())
|
||||
Ok(spans)
|
||||
}
|
||||
|
||||
/// Close the gap containing `t` on a track (Ripple Delete on a gap).
|
||||
@@ -559,8 +604,9 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
||||
|
||||
/// Ripple-trim one edge of a group of linked items (e.g. a clip and its audio partners) as a single
|
||||
/// edit: every member's edge moves by the same delta, later material on the members' tracks and on
|
||||
/// sync-locked tracks shifts by the change. Only tracks without a group member can block the edit
|
||||
/// (a sync-locked track whose material would be overwritten).
|
||||
/// sync-locked tracks shifts by the change, and so does a clip that starts before the cut, reaches
|
||||
/// past it and is linked to a clip that shifts (a split edit). The edit is refused when it would
|
||||
/// overwrite material on a sync-locked track, or when such a linked clip has no room to follow.
|
||||
pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
|
||||
let Some(&first) = clips.first() else { return Ok(Tick::ZERO) };
|
||||
let mut d = delta;
|
||||
@@ -606,27 +652,54 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
|
||||
origins.iter().find(|(t, _)| *t == tid).map(|o| o.1).unwrap_or_default()
|
||||
};
|
||||
let shift = if edge == Edge::In { -d } else { d };
|
||||
for tr in work.all_tracks_mut() {
|
||||
if tr.locked {
|
||||
continue;
|
||||
}
|
||||
let member = origins.iter().find(|(t, _)| *t == tr.id).map(|o| o.1);
|
||||
if member.is_none() && !tr.sync_lock {
|
||||
continue;
|
||||
}
|
||||
let from = member.unwrap_or(main_from);
|
||||
if member.is_none() && shift < Tick::ZERO {
|
||||
// where the shift starts on each track that ripples: the members' tracks and the sync-locked ones
|
||||
let rippling: Vec<(TrackId, Tick, bool)> = work
|
||||
.all_tracks()
|
||||
.filter(|tr| !tr.locked)
|
||||
.filter_map(|tr| match origins.iter().find(|(t, _)| *t == tr.id) {
|
||||
Some(o) => Some((tr.id, o.1, true)),
|
||||
None => tr.sync_lock.then_some((tr.id, main_from, false)),
|
||||
})
|
||||
.collect();
|
||||
// A split edit: the linked partner of a later clip can start before the shift does and reach
|
||||
// past it (its sound leads the cut). It follows the clip it is linked to, or the two would
|
||||
// drift apart.
|
||||
// (the trimmed clips' own partners are trimmed, or left alone, by the caller: they never follow)
|
||||
let own_links: Vec<u64> = clips.iter().filter_map(|c| work.find_item(*c).and_then(|(_, i)| i.link)).collect();
|
||||
let mut shifting_links: Vec<u64> = Vec::new();
|
||||
for (tid, from, _) in &rippling {
|
||||
let Some(tr) = work.track(*tid) else { continue };
|
||||
let later = tr.items.iter().filter(|i| !clips.contains(&i.id) && i.start >= *from);
|
||||
shifting_links.extend(later.filter_map(|i| i.link).filter(|l| !own_links.contains(l)));
|
||||
}
|
||||
for (tid, from, member) in rippling {
|
||||
let Some(tr) = work.track_mut(tid) else { continue };
|
||||
let follows = |i: &TrackItem| i.start < from && i.end() > from && i.link.is_some_and(|l| shifting_links.contains(&l));
|
||||
if !member && shift < Tick::ZERO {
|
||||
let at = if edge == Edge::In { from - Tick(1) } else { from };
|
||||
if !track_range_empty(tr, TimeRange::new(at + shift, -shift)) {
|
||||
let closing = TimeRange::new(at + shift, -shift);
|
||||
if tr.items.iter().any(|i| i.range().overlaps(&closing) && !follows(i)) {
|
||||
return Err(EditError::SyncLockConflict);
|
||||
}
|
||||
}
|
||||
let followers: Vec<ClipId> = tr.items.iter().filter(|i| !clips.contains(&i.id) && follows(i)).map(|i| i.id).collect();
|
||||
for i in &mut tr.items {
|
||||
if !clips.contains(&i.id) && i.start >= from {
|
||||
if !clips.contains(&i.id) && (i.start >= from || followers.contains(&i.id)) {
|
||||
i.start += shift;
|
||||
}
|
||||
}
|
||||
tr.sort();
|
||||
// a transition into a follower from a clip that stayed behind no longer sits on a cut
|
||||
let ends: Vec<(ClipId, Tick)> = tr.items.iter().map(|i| (i.id, i.end())).collect();
|
||||
let starts: Vec<(ClipId, Tick)> = tr.items.iter().map(|i| (i.id, i.start)).collect();
|
||||
tr.transitions.retain(|t| match (t.from, t.to) {
|
||||
(Some(a), Some(b)) if followers.contains(&b) => ends.iter().find(|e| e.0 == a).map(|e| e.1) == starts.iter().find(|x| x.0 == b).map(|x| x.1),
|
||||
_ => true,
|
||||
});
|
||||
// a follower with no room (a clip in its way, or the sequence start) blocks the edit
|
||||
if tr.items.first().is_some_and(|i| i.start < Tick::ZERO) || tr.items.windows(2).any(|w| w[0].end() > w[1].start) {
|
||||
return Err(EditError::SyncLockConflict);
|
||||
}
|
||||
}
|
||||
transitions_follow_cuts(seq, &mut work);
|
||||
work.check().map_err(EditError::Other)?;
|
||||
@@ -751,41 +824,70 @@ pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, c
|
||||
|
||||
/// Set speed/duration (Clip ▸ Speed/Duration…). `ripple` shifts following material.
|
||||
pub fn set_speed(seq: &mut Sequence, clip: ClipId, speed: f64, reverse: bool, ripple: bool, ctx: &mut EditCtx) -> Result<()> {
|
||||
if speed <= 0.0 {
|
||||
set_speed_group(seq, &[clip], speed, reverse, ripple, ctx)
|
||||
}
|
||||
|
||||
/// Speed/Duration on a clip and its linked partners on other tracks (its sound) as a single edit.
|
||||
/// `clips` holds at most one clip per track. Every member takes the speed, and with `ripple` the
|
||||
/// later material moves once, on the members' tracks and on sync-locked tracks. Rippling each
|
||||
/// member on its own would move the later clips once per member. When the members' lengths change
|
||||
/// by different amounts (a split edit), the later material moves by the largest change, so no
|
||||
/// member is overlapped and the tracks stay in sync. Nothing changes when the edit fails.
|
||||
pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse: bool, ripple: bool, ctx: &mut EditCtx) -> Result<()> {
|
||||
if !(speed.is_finite() && speed > 0.0) {
|
||||
return Err(EditError::Other("speed must be positive".into()));
|
||||
}
|
||||
let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
|
||||
let src_len = it.source_out() - it.source_in;
|
||||
let mut new_dur = Tick((src_len.0 as f64 / speed).round() as i64).max(ctx.min_duration);
|
||||
let old_end = it.end();
|
||||
let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?;
|
||||
let (_, next_start) = neighbours(tr, clip);
|
||||
if !ripple {
|
||||
new_dur = new_dur.min(next_start - it.start);
|
||||
}
|
||||
let delta = new_dur - it.duration;
|
||||
let before = ripple.then(|| seq.clone());
|
||||
{
|
||||
let (_, it) = seq.find_item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
let mut work = seq.clone();
|
||||
// per member track: where its later material starts, and by how much the member's length changed
|
||||
let mut origins: Vec<(TrackId, Tick, Tick)> = Vec::new();
|
||||
for clip in clips {
|
||||
let (tid, it) = work.find_item(*clip).ok_or(EditError::NoItem(*clip))?;
|
||||
if origins.iter().any(|o| o.0 == tid) {
|
||||
return Err(EditError::Other("clips on one track change speed one at a time".into()));
|
||||
}
|
||||
let src_len = it.source_out() - it.source_in;
|
||||
// a tiny speed saturates the cast: keep the clip's end representable
|
||||
let room = Tick(Tick::MAX.0.saturating_sub(it.start.0.max(0)));
|
||||
let mut new_dur = Tick((src_len.0 as f64 / speed).round() as i64).min(room).max(ctx.min_duration);
|
||||
let old_end = it.end();
|
||||
let tr = work.track(tid).ok_or(EditError::NoTrack(tid))?;
|
||||
let (_, next_start) = neighbours(tr, *clip);
|
||||
if !ripple {
|
||||
new_dur = new_dur.min(next_start - it.start);
|
||||
}
|
||||
let delta = new_dur - it.duration;
|
||||
let (_, it) = work.find_item_mut(*clip).ok_or(EditError::NoItem(*clip))?;
|
||||
it.speed = speed;
|
||||
it.reverse = reverse;
|
||||
it.duration = new_dur;
|
||||
origins.push((tid, old_end, delta));
|
||||
}
|
||||
if ripple && delta != Tick::ZERO {
|
||||
for tr in seq.all_tracks_mut() {
|
||||
if !tr.locked && (tr.id == tid || tr.sync_lock) {
|
||||
for i in &mut tr.items {
|
||||
if i.id != clip && i.start >= old_end {
|
||||
i.start += delta;
|
||||
}
|
||||
}
|
||||
tr.sort();
|
||||
let main_from = origins.first().map(|o| o.1).unwrap_or_default();
|
||||
let shift = origins.iter().map(|o| o.2).max().unwrap_or_default();
|
||||
if ripple && shift != Tick::ZERO {
|
||||
for tr in work.all_tracks_mut() {
|
||||
if tr.locked {
|
||||
continue;
|
||||
}
|
||||
let member = origins.iter().find(|o| o.0 == tr.id).map(|o| o.1);
|
||||
if member.is_none() && !tr.sync_lock {
|
||||
continue;
|
||||
}
|
||||
let from = member.unwrap_or(main_from);
|
||||
for i in &mut tr.items {
|
||||
if !clips.contains(&i.id) && i.start >= from {
|
||||
// later clips must still end on the representable timeline
|
||||
let start = i.start.0.checked_add(shift.0).filter(|s| s.checked_add(i.duration.0).is_some());
|
||||
i.start = Tick(start.ok_or_else(|| EditError::Other("the speed change would move later clips past the end of the timeline".into()))?);
|
||||
}
|
||||
}
|
||||
tr.sort();
|
||||
}
|
||||
}
|
||||
if let Some(before) = before {
|
||||
transitions_follow_cuts(&before, seq);
|
||||
if ripple {
|
||||
transitions_follow_cuts(seq, &mut work);
|
||||
}
|
||||
*seq = work;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,81 @@ fn ripple_delete_and_conflict() {
|
||||
assert_eq!(fx.spans(v1), vec![(0, 10), (20, 10)], "unchanged on failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ripple_delete_closes_the_time_free_on_every_track() {
|
||||
// four clips on V1 with their sounds on A1; the second sound starts 2 early and ends 2 late
|
||||
let build = || {
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let v: Vec<ClipId> = [(0, 10), (10, 10), (20, 10), (30, 10)].iter().map(|(s, d)| fx.put(v1, *s, *d, 0)).collect();
|
||||
let a: Vec<ClipId> = [(0, 8), (8, 14), (22, 8), (30, 10)].iter().map(|(s, d)| fx.put(a1, *s, *d, 0)).collect();
|
||||
(fx, v, a)
|
||||
};
|
||||
let span = |start, len| TimeRange::new(f(start), f(len));
|
||||
// one clip with a split edit on both sides: the picture's stretch closes, everything later stays in sync
|
||||
let (mut fx, v, a) = build();
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[1], a[1]]), Ok(vec![span(10, 10)]));
|
||||
assert_eq!(fx.spans(fx.v(0)), vec![(0, 10), (10, 10), (20, 10)]);
|
||||
assert_eq!(fx.spans(fx.a(0)), vec![(0, 8), (12, 8), (20, 10)]);
|
||||
fx.seq.check().unwrap();
|
||||
// two neighbours with a split edit between them close as one stretch
|
||||
let (mut fx, v, a) = build();
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[1], a[1], v[2], a[2]]), Ok(vec![span(10, 20)]));
|
||||
assert_eq!(fx.spans(fx.v(0)), vec![(0, 10), (10, 10)]);
|
||||
assert_eq!(fx.spans(fx.a(0)), vec![(0, 8), (10, 10)]);
|
||||
// clips apart in time each close their own gap
|
||||
let (mut fx, v, a) = build();
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[0], a[0], v[3], a[3]]), Ok(vec![span(0, 8), span(30, 10)]));
|
||||
fx.seq.check().unwrap();
|
||||
|
||||
// straight cuts with the sounds on alternating tracks: V1 [0,10) [10,20) [20,30), A1 [0,10) [20,30), A2 [10,20)
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1, a2) = (fx.v(0), fx.a(0), fx.a(1));
|
||||
let v: Vec<ClipId> = [0, 10, 20].iter().map(|s| fx.put(v1, *s, 10, 0)).collect();
|
||||
let (s0, s1) = (fx.put(a1, 0, 10, 0), fx.put(a2, 10, 10, 0));
|
||||
fx.put(a1, 20, 10, 0);
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[0], s0, v[1], s1]), Ok(vec![span(0, 20)]));
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1), fx.spans(a2)), (vec![(0, 10)], vec![(0, 10)], vec![]));
|
||||
|
||||
// a sound shorter than its picture with nothing round it: the whole picture closes
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let (p, s) = (fx.put(v1, 10, 10, 0), fx.put(a1, 12, 6, 0));
|
||||
fx.put(v1, 20, 10, 0);
|
||||
fx.put(a1, 20, 10, 0);
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[p, s]), Ok(vec![span(10, 10)]));
|
||||
// with the neighbours' sound right up against it, only the sound's stretch can close
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
fx.put(a1, 0, 12, 0);
|
||||
let (p, s) = (fx.put(v1, 10, 10, 0), fx.put(a1, 12, 6, 0));
|
||||
fx.put(v1, 20, 10, 0);
|
||||
fx.put(a1, 18, 12, 0);
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[p, s]), Ok(vec![span(12, 6)]));
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(14, 10)], vec![(0, 12), (12, 12)]));
|
||||
|
||||
// a linked sound on a locked track stays, and does not shrink the gap that closes
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let (p, s) = (fx.put(v1, 10, 10, 0), fx.put(a1, 12, 6, 0));
|
||||
fx.put(v1, 20, 10, 0);
|
||||
fx.seq.track_mut(a1).unwrap().locked = true;
|
||||
fx.seq.track_mut(a1).unwrap().sync_lock = false;
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[p, s]), Ok(vec![span(10, 10)]));
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(10, 10)], vec![(12, 6)]));
|
||||
|
||||
// nothing can close: what stays on each track covers the other track's deleted time. Refused, unchanged.
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let x = fx.put(v1, 10, 10, 0);
|
||||
fx.put(v1, 30, 10, 0);
|
||||
fx.put(a1, 10, 10, 0);
|
||||
let y = fx.put(a1, 30, 10, 0);
|
||||
let before = fx.seq.clone();
|
||||
assert!(matches!(ripple_delete_items(&mut fx.seq, &[x, y]), Err(EditError::Other(_))));
|
||||
assert_eq!(fx.seq, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_gap_works() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -192,6 +267,79 @@ fn ripple_trim_shifts_following() {
|
||||
assert_eq!(fx.spans(v1), vec![(0, 12), (12, 10)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ripple_trim_group_takes_a_split_edit_along() {
|
||||
// V1: a [0,10) then b [10,20). b's linked sound leads it by 4 on sync-locked A1: [6,20).
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let a = fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
let sound = fx.put(a1, 6, 14, 0);
|
||||
for c in [b, sound] {
|
||||
fx.seq.find_item_mut(c).unwrap().1.link = Some(7);
|
||||
}
|
||||
let mut n = fx.next;
|
||||
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 13), (13, 10)], vec![(9, 14)]), "longer: the early sound follows its picture");
|
||||
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, -f(5), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 8), (8, 10)], vec![(4, 14)]), "shorter: it follows too");
|
||||
// the sound would start before the sequence does: refused, nothing changes
|
||||
assert_eq!(ripple_trim_group(&mut fx.seq, &[a], Edge::Out, -f(5), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
|
||||
// another clip right before the sound leaves it no room either
|
||||
fx.put(a1, 0, 4, 0);
|
||||
assert_eq!(ripple_trim_group(&mut fx.seq, &[a], Edge::Out, -f(2), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 8), (8, 10)], vec![(0, 4), (4, 14)]), "unchanged on failure");
|
||||
// a linked clip that ends before the cut is not a split edit and stays where it is
|
||||
let early = fx.put(fx.a(1), 0, 3, 0);
|
||||
fx.seq.find_item_mut(early).unwrap().1.link = Some(7);
|
||||
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, f(2), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(fx.spans(fx.a(1)), vec![(0, 3)]);
|
||||
fx.seq.check().unwrap();
|
||||
|
||||
// In edge: V1 b [10,20), c [20,30); c's sound starts 12 early on A1: [8,30). b loses 3 at its head.
|
||||
let mut fx = Fx::new();
|
||||
let (v1, v2, a1, a2) = (fx.v(0), fx.v(1), fx.a(0), fx.a(1));
|
||||
fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
let c = fx.put(v1, 20, 10, 0);
|
||||
let c_sound = fx.put(a1, 8, 22, 0);
|
||||
// two more clips linked to b that the caller did not put in the group
|
||||
let b_sound = fx.put(a2, 10, 10, 0);
|
||||
let b_top = fx.put(v2, 12, 8, 0);
|
||||
for (x, link) in [(c, 7), (c_sound, 7), (b, 8), (b_sound, 8), (b_top, 8)] {
|
||||
fx.seq.find_item_mut(x).unwrap().1.link = Some(link);
|
||||
}
|
||||
let mut n = fx.next;
|
||||
ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 10), (10, 7), (17, 10)], vec![(5, 22)]), "c and its early sound move up together");
|
||||
assert_eq!((fx.spans(v2), fx.spans(a2)), (vec![(9, 8)], vec![(10, 10)]), "the trimmed clip's own partners never follow");
|
||||
|
||||
// a crossfade into the early sound from a clip that stays behind no longer sits on a cut: it goes
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let a = fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
let stays = fx.put(a1, 0, 6, 0);
|
||||
let sound = fx.put(a1, 6, 14, 0);
|
||||
for x in [b, sound] {
|
||||
fx.seq.find_item_mut(x).unwrap().1.link = Some(7);
|
||||
}
|
||||
fx.seq.track_mut(a1).unwrap().transitions = vec![Transition {
|
||||
id: TransitionId(1),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(4),
|
||||
duration: f(4),
|
||||
from: Some(stays),
|
||||
to: Some(sound),
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
}];
|
||||
let mut n = fx.next;
|
||||
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(fx.spans(a1), vec![(0, 6), (9, 14)]);
|
||||
assert!(fx.seq.track(a1).unwrap().transitions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roll_slip_slide() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -269,6 +417,44 @@ fn rate_stretch_and_speed() {
|
||||
assert_eq!(fx.spans(v1), vec![(0, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_on_a_linked_group_ripples_once() {
|
||||
// V1: a [0,10), b [10,20). A1: a's sound [0,10), b's sound [10,20).
|
||||
let build = |sound_len: i64| {
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let a = fx.put(v1, 0, 10, 0);
|
||||
fx.put(v1, 10, 10, 0);
|
||||
let sound = fx.put(a1, 0, sound_len, 0);
|
||||
fx.put(a1, sound_len, 10, 0);
|
||||
(fx, a, sound)
|
||||
};
|
||||
for (speed, picture, later) in [(0.5, (0, 20), (20, 10)), (2.0, (0, 5), (5, 10))] {
|
||||
let (mut fx, a, sound) = build(10);
|
||||
let mut n = fx.next;
|
||||
set_speed_group(&mut fx.seq, &[a, sound], speed, false, true, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(fx.v(0)), fx.spans(fx.a(0))), (vec![picture, later], vec![picture, later]), "{speed}");
|
||||
}
|
||||
// a split edit: the sound is 4 longer. Later clips move by the larger change, on both tracks.
|
||||
let (mut fx, a, sound) = build(14);
|
||||
let mut n = fx.next;
|
||||
set_speed_group(&mut fx.seq, &[a, sound], 0.5, false, true, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(fx.v(0)), fx.spans(fx.a(0))), (vec![(0, 20), (24, 10)], vec![(0, 28), (28, 10)]));
|
||||
let (mut fx, a, sound) = build(14);
|
||||
let mut n = fx.next;
|
||||
set_speed_group(&mut fx.seq, &[a, sound], 2.0, false, true, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(fx.v(0)), fx.spans(fx.a(0))), (vec![(0, 5), (5, 10)], vec![(0, 7), (9, 10)]));
|
||||
fx.seq.check().unwrap();
|
||||
// two clips of one track are not one group; a missing clip changes nothing
|
||||
let (mut fx, a, _) = build(10);
|
||||
let b = fx.seq.video_tracks[0].items[1].id;
|
||||
let before = fx.seq.clone();
|
||||
let mut n = fx.next;
|
||||
assert!(set_speed_group(&mut fx.seq, &[a, b], 2.0, false, true, &mut Fx::ctx(&mut n)).is_err());
|
||||
assert!(set_speed_group(&mut fx.seq, &[a, ClipId(1)], 2.0, false, true, &mut Fx::ctx(&mut n)).is_err());
|
||||
assert_eq!(fx.seq, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_with_insert_and_overwrite() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -367,3 +553,21 @@ fn merge(v: Vec<(i64, i64)>) -> Vec<(i64, i64)> {
|
||||
fn unused_kind_import() {
|
||||
let _ = TrackKind::Video;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_speeds_are_refused_or_clamped_never_overflow() {
|
||||
// a tiny speed used to saturate the new duration to i64::MAX, then the ripple shift overflowed
|
||||
for speed in [1e-7, 1e-300, f64::MIN_POSITIVE, f64::NAN, f64::INFINITY, -1.0, 0.0] {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let _b = fx.put(v1, 10, 10, 10);
|
||||
let before = fx.seq.clone();
|
||||
let mut n = fx.next;
|
||||
let r = set_speed_group(&mut fx.seq, &[a], speed, false, true, &mut Fx::ctx(&mut n));
|
||||
match r {
|
||||
Ok(()) => fx.seq.check().unwrap(),
|
||||
Err(_) => assert_eq!(format!("{:?}", fx.seq), format!("{before:?}"), "{speed}: a refused edit changes nothing"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,9 +237,15 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
can_replace_from_source,
|
||||
|s, p| replace_with(s, p, Replace::MatchFrame),
|
||||
),
|
||||
spec("clip.replaceFromBin", "From Bin", &["Clip", "Replace With Clip"], None, r#"{"clips":[id]?,"item":id?}"#, can_replace_from_bin, |s, p| {
|
||||
replace_with(s, p, Replace::Bin)
|
||||
}),
|
||||
spec(
|
||||
"clip.replaceFromBin",
|
||||
"From Bin",
|
||||
&["Clip", "Replace With Clip"],
|
||||
None,
|
||||
r#"{"clips":[id]?,"item":id?,"keepSourceIn":bool=false}"#,
|
||||
can_replace_from_bin,
|
||||
|s, p| replace_with(s, p, Replace::Bin),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1664,12 +1670,22 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
|
||||
Replace::Bin => item_range(&s.project, new_item, s.prefs.timeline.still_duration(s.sequence_rate())).map(|r| r.start).unwrap_or_default(),
|
||||
_ => source_range(s).map(|(_, r)| r.start).unwrap_or_default(),
|
||||
};
|
||||
// From Bin starts at the item's In point, as Premiere does; `keepSourceIn` keeps each clip's own
|
||||
let keep_in = how == Replace::Bin && bool_p(p, "keepSourceIn").unwrap_or(false);
|
||||
// ...but a subclip that restricts trims has no media outside its range: a kept In stays on
|
||||
// one of its frames (first frame, last frame)
|
||||
let keep_within = match &pi.kind {
|
||||
ItemKind::Subclip { range, restrict_trims: true, .. } => {
|
||||
Some((range.start, (range.end() - item_rate(&s.project, new_item).frame_duration()).max(range.start)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let src_ph = s.state.source_playhead;
|
||||
let ph = s.playhead();
|
||||
let targets = with_links(s, &clips_p(s, p));
|
||||
let src_channels = pi.as_media().and_then(|m| m.interpret.audio_channels.as_ref()).and_then(|a| a.clips.first().cloned()).unwrap_or_default();
|
||||
let replaced = s.edit_sequence("Replace With Clip", |q, _, _| {
|
||||
let mut out = Vec::new();
|
||||
let (replaced, clamped) = s.edit_sequence("Replace With Clip", |q, _, _| {
|
||||
let (mut out, mut clamped) = (Vec::new(), Vec::new());
|
||||
for c in &targets {
|
||||
let Some((tid, _)) = q.find_item(*c) else { continue };
|
||||
let kind = q.track(tid).map(|t| t.kind).unwrap_or_default();
|
||||
@@ -1680,6 +1696,13 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
|
||||
let src_in = match how {
|
||||
// the Source monitor frame lines up with the sequence playhead
|
||||
Replace::MatchFrame => src_ph - Tick(((ph - it.start).0 as f64 * it.speed.abs()).round() as i64),
|
||||
_ if keep_in => {
|
||||
let kept = keep_within.map(|(first, last)| it.source_in.clamp(first, last)).unwrap_or(it.source_in);
|
||||
if kept != it.source_in {
|
||||
clamped.push(c.0);
|
||||
}
|
||||
kept
|
||||
}
|
||||
_ => in_point,
|
||||
};
|
||||
if src_in < Tick::ZERO {
|
||||
@@ -1699,7 +1722,37 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("the replacement has no video or audio for the selected clips".into()));
|
||||
}
|
||||
Ok(out)
|
||||
Ok((out, clamped))
|
||||
})?;
|
||||
Ok(json!({"clips": replaced, "item": new_item.0}))
|
||||
let mut result = json!({"clips": replaced, "item": new_item.0, "short": short_clips(s, &replaced)});
|
||||
if keep_in {
|
||||
// the clips whose In was outside a trims-restricted subclip and now sits on its nearest frame
|
||||
result["sourceInClamped"] = json!(clamped);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The clips among `clips` that play past the end of their media, with how many sequence frames
|
||||
/// of the clip have no media (at most the clip's own length). The edit is left as it is (the
|
||||
/// renderer holds the last frame there); this is so whoever replaced the clip knows.
|
||||
fn short_clips(s: &Session, clips: &[u64]) -> Vec<Value> {
|
||||
let Some(q) = s.active_sequence() else { return Vec::new() };
|
||||
let frame = q.settings.frame_rate.frame_duration().0.max(1);
|
||||
clips
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let (_, it) = q.find_item(ClipId(*c))?;
|
||||
// stills, mattes and other sources without an end are never short; nor is a held frame
|
||||
let media_end = crate::media_duration(&s.project, &s.media, it.item)?;
|
||||
let over = it.source_out().0.saturating_sub(media_end.0);
|
||||
if over <= 0 || it.frame_hold.is_some() {
|
||||
return None;
|
||||
}
|
||||
// media ticks past the end, as sequence ticks of this clip
|
||||
let length = it.duration.0.max(0);
|
||||
let ticks = ((over as f64 / it.speed.abs().max(1e-9)).round().min(length as f64) as i64).min(length);
|
||||
let frames = ticks.saturating_add(frame - 1) / frame;
|
||||
(frames > 0).then(|| json!({"clip": c, "shortByFrames": frames}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -602,6 +602,154 @@ fn replace_with_clip_from_source_match_frame_and_bin() {
|
||||
assert!(s.execute("clip.replaceFromBin", json!({"item": music.0})).is_err());
|
||||
}
|
||||
|
||||
/// Replace With Clip ▸ From Bin restarts the clip at the new item's In point (as Premiere does);
|
||||
/// `keepSourceIn` keeps the clip's place in the media instead.
|
||||
#[test]
|
||||
fn replace_from_bin_keeps_the_source_in_on_request() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
let c = v1(&s)[3].clone();
|
||||
let dunes = item_named(&s, "Desert_Dunes.mp4");
|
||||
let forest = item_named(&s, "Misty_Forest.mp4");
|
||||
// disabled without a clip and a replacement, with or without the new parameter
|
||||
s.execute("timeline.select", json!({"clips": []})).unwrap();
|
||||
assert!(matches!(s.execute("clip.replaceFromBin", json!({"keepSourceIn": true})), Err(crate::EngineError::Disabled(..))));
|
||||
// give the clip a source In that is not the start of its media
|
||||
s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "in", "mode": "regular", "deltaFrames": 7})).unwrap();
|
||||
let trimmed = clip(&s, c.id.0);
|
||||
assert_eq!(trimmed.source_in, c.source_in + rate.tick_of(7));
|
||||
select(&mut s, &[c.id.0]);
|
||||
s.execute("project.select", json!({"items": [dunes.0]})).unwrap();
|
||||
let before = s.project.clone();
|
||||
// default: the replacement starts at the item's In point, and the result is what it was
|
||||
let r = s.execute("clip.replaceFromBin", json!({})).unwrap();
|
||||
assert_eq!((clip(&s, c.id.0).item, clip(&s, c.id.0).source_in), (dunes, Tick::ZERO));
|
||||
assert_eq!(r, json!({"clips": [c.id.0], "item": dunes.0, "short": []}));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
// keepSourceIn: the same place in the new media, everything else as before
|
||||
let r = s.execute("clip.replaceFromBin", json!({"keepSourceIn": true})).unwrap();
|
||||
let got = clip(&s, c.id.0);
|
||||
assert_eq!((got.item, got.source_in, got.start, got.duration), (dunes, trimmed.source_in, trimmed.start, trimmed.duration));
|
||||
assert_eq!(r, json!({"clips": [c.id.0], "item": dunes.0, "short": [], "sourceInClamped": []}));
|
||||
let after = s.project.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *after);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("clip.replaceFromBin", json!({"keepSourceIn": false})).unwrap();
|
||||
assert_eq!(clip(&s, c.id.0).source_in, Tick::ZERO);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// a subclip that restricts trims has no media outside its range: a kept In before or after it
|
||||
// moves to its first or last frame, and the result names the clip
|
||||
let sub = |s: &mut Session, from: Tick, to: Tick, restrict: bool| {
|
||||
let r = s.execute("clip.makeSubclip", json!({"item": forest.0, "start": from.0, "end": to.0, "restrictTrims": restrict})).unwrap();
|
||||
ItemId(r["item"].as_u64().unwrap())
|
||||
};
|
||||
let kept = trimmed.source_in;
|
||||
let media_frame = s.project.item(forest).unwrap().as_media().unwrap().frame_rate().frame_duration();
|
||||
assert!(kept > rate.tick_of(10) && media_frame > Tick::ZERO);
|
||||
let later = sub(&mut s, kept + rate.tick_of(3), kept + rate.tick_of(15), true);
|
||||
let earlier = sub(&mut s, Tick::ZERO, kept - rate.tick_of(2), true);
|
||||
let loose = sub(&mut s, kept + rate.tick_of(3), kept + rate.tick_of(15), false);
|
||||
for (item, want, clamped) in [(later, kept + rate.tick_of(3), true), (earlier, kept - rate.tick_of(2) - media_frame, true), (loose, kept, false)] {
|
||||
let r = s.execute("clip.replaceFromBin", json!({"item": item.0, "keepSourceIn": true})).unwrap();
|
||||
let got = clip(&s, c.id.0);
|
||||
assert_eq!((got.item, got.source_in, got.start, got.duration), (item, want, trimmed.start, trimmed.duration));
|
||||
assert_eq!(r["sourceInClamped"], if clamped { json!([c.id.0]) } else { json!([]) });
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
// a kept In that is already inside the subclip stays where it is
|
||||
let around = sub(&mut s, kept - rate.tick_of(5), kept + rate.tick_of(23), true);
|
||||
let r = s.execute("clip.replaceFromBin", json!({"item": around.0, "keepSourceIn": true})).unwrap();
|
||||
assert_eq!((clip(&s, c.id.0).source_in, &r["sourceInClamped"]), (kept, &json!([])));
|
||||
// hostile values of the flag fall back to the default
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
for junk in [json!("yes"), json!(1), json!(null), json!([true])] {
|
||||
let r = s.execute("clip.replaceFromBin", json!({"item": dunes.0, "keepSourceIn": junk})).unwrap();
|
||||
assert_eq!((clip(&s, c.id.0).source_in, r.get("sourceInClamped")), (Tick::ZERO, None));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A replacement shorter than the clip's slot left the clip running past the end of its media
|
||||
/// without a word. The three Replace With Clip commands now say which clips are short, by how
|
||||
/// many sequence frames.
|
||||
#[test]
|
||||
fn replace_with_clip_reports_clips_that_run_past_the_new_media() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let demo_seq = s.state.active_sequence.unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
let c = v1(&s)[3].clone();
|
||||
let slot = rate.frame_at(c.duration);
|
||||
let forest = item_named(&s, "Misty_Forest.mp4");
|
||||
let forest_len = s.project.item(forest).unwrap().as_media().unwrap().info.duration;
|
||||
assert!(slot > 12 && forest_len > c.duration + rate.tick_of(40), "the clip is longer than 12 frames and shorter than the media");
|
||||
let state = |s: &Session| {
|
||||
let it = clip(s, c.id.0);
|
||||
(it.item, it.source_in, it.start, it.duration)
|
||||
};
|
||||
select(&mut s, &[c.id.0]);
|
||||
|
||||
// From Bin, with 12 frames of media (a subclip that restricts trims): the edit is what it
|
||||
// always was, and the result says the clip is short
|
||||
let r = s.execute("clip.makeSubclip", json!({"item": forest.0, "start": rate.tick_of(10).0, "end": rate.tick_of(22).0})).unwrap();
|
||||
let short = ItemId(r["item"].as_u64().unwrap());
|
||||
let before = s.project.clone();
|
||||
let r = s.execute("clip.replaceFromBin", json!({"item": short.0})).unwrap();
|
||||
assert_eq!(state(&s), (short, rate.tick_of(10), c.start, c.duration), "the edit itself is not changed");
|
||||
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": slot - 12}]));
|
||||
let after = s.project.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *after);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// long enough media: nothing is short
|
||||
assert_eq!(s.execute("clip.replaceFromBin", json!({"item": forest.0})).unwrap()["short"], json!([]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// From Source Monitor, with the Source In 20 frames before the end of the media
|
||||
let frames = |t: Tick| (t.0 + rate.frame_duration().0 - 1) / rate.frame_duration().0;
|
||||
s.execute("source.open", json!({"item": forest.0})).unwrap();
|
||||
let mark = forest_len - rate.tick_of(20);
|
||||
s.execute("project.setMarks", json!({"item": forest.0, "in": mark.0})).unwrap();
|
||||
let r = s.execute("clip.replaceFromSource", json!({})).unwrap();
|
||||
let got = clip(&s, c.id.0);
|
||||
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": frames(got.source_out() - forest_len)}]));
|
||||
assert_eq!(frames(got.source_out() - forest_len), slot - 20);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Match Frame: the last frame of the media on the clip's first frame
|
||||
s.execute("playhead.set", json!({"time": c.start.0})).unwrap();
|
||||
s.execute("source.setPlayhead", json!({"time": (forest_len - rate.tick_of(1)).0})).unwrap();
|
||||
let r = s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap();
|
||||
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": slot - 1}]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// with enough media after the frame, nothing is short
|
||||
s.execute("source.setPlayhead", json!({"frame": 5})).unwrap();
|
||||
assert_eq!(s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap()["short"], json!([]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// never more than the clip itself: an In that lies past the end of the new media altogether
|
||||
s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "in", "mode": "regular", "deltaFrames": 7})).unwrap();
|
||||
let trimmed = clip(&s, c.id.0);
|
||||
s.execute("file.newSequence", json!({"name": "Brief"})).unwrap();
|
||||
let brief = s.state.active_sequence.unwrap();
|
||||
s.execute("timeline.place", json!({"item": forest.0, "time": 0})).unwrap();
|
||||
let placed = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
|
||||
s.execute("timeline.trim", json!({"clip": placed.id.0, "edge": "out", "mode": "regular", "delta": (rate.tick_of(3) - placed.duration).0})).unwrap();
|
||||
s.execute("sequence.open", json!({"item": demo_seq.0})).unwrap();
|
||||
select(&mut s, &[c.id.0]);
|
||||
assert!(trimmed.source_in > s.project.sequence(brief).unwrap().duration(), "the kept In is past the end of the nested sequence");
|
||||
let r = s.execute("clip.replaceFromBin", json!({"clips": [c.id.0], "item": brief.0, "keepSourceIn": true})).unwrap();
|
||||
assert_eq!(clip(&s, c.id.0).source_in, trimmed.source_in);
|
||||
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": rate.frame_at(trimmed.duration)}]), "all of the clip, not more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_source_channels_pick_what_a_clip_plays() {
|
||||
let mut it = v1(&demo())[0].clone();
|
||||
|
||||
+169
-44
@@ -110,6 +110,33 @@ fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The ids a caller named under `many` (a list) or `one` (a single id), when the command documents
|
||||
/// that key as ids (`"clips":[id]`, `"item":id`); `None` when it named none.
|
||||
fn named_ids(spec: &CommandSpec, p: &Value, many: &str, one: &str) -> Option<Vec<u64>> {
|
||||
let ids: Vec<u64> = if spec.params.contains(&format!("\"{many}\":[id]"))
|
||||
&& let Some(a) = p.get(many).and_then(Value::as_array)
|
||||
{
|
||||
a.iter().filter_map(Value::as_u64).collect()
|
||||
} else if spec.params.contains(&format!("\"{one}\":id")) {
|
||||
u64_p(p, one).into_iter().collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(!ids.is_empty()).then_some(ids)
|
||||
}
|
||||
/// Timeline clips named explicitly in `clips` / `clip` (see [`Session::execute`]): only clips of
|
||||
/// the active sequence count, so an id of nothing does not stand in for a selection.
|
||||
pub(crate) fn named_clips(s: &Session, spec: &CommandSpec, p: &Value) -> Option<Vec<ClipId>> {
|
||||
let seq = s.active_sequence()?;
|
||||
let clips: Vec<ClipId> = named_ids(spec, p, "clips", "clip")?.into_iter().map(ClipId).filter(|c| seq.find_item(*c).is_some()).collect();
|
||||
(!clips.is_empty()).then_some(clips)
|
||||
}
|
||||
/// Project items named explicitly in `items` / `item`, likewise.
|
||||
pub(crate) fn named_items(s: &Session, spec: &CommandSpec, p: &Value) -> Option<Vec<ItemId>> {
|
||||
let items: Vec<ItemId> = named_ids(spec, p, "items", "item")?.into_iter().map(ItemId).filter(|i| s.project.item(*i).is_some()).collect();
|
||||
(!items.is_empty()).then_some(items)
|
||||
}
|
||||
|
||||
// ---------- param helpers ----------
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
@@ -187,23 +214,88 @@ pub(crate) fn clips_p(s: &Session, p: &Value) -> Vec<ClipId> {
|
||||
None => clip_p(p, "clip").map(|c| vec![c]).unwrap_or_else(|| s.state.selection.clone()),
|
||||
}
|
||||
}
|
||||
pub(crate) fn track_p(s: &Session, p: &Value, k: &str) -> Option<TrackId> {
|
||||
let v = p.get(k)?;
|
||||
if let Some(id) = v.as_u64() {
|
||||
return Some(TrackId(id));
|
||||
}
|
||||
// "V1", "A2"
|
||||
let name = v.as_str()?;
|
||||
let seq = s.active_sequence()?;
|
||||
let (kind, idx) = name.split_at(1);
|
||||
let i: usize = idx.parse().ok()?;
|
||||
let tracks = if kind.eq_ignore_ascii_case("v") { &seq.video_tracks } else { &seq.audio_tracks };
|
||||
tracks.get(i.checked_sub(1)?).map(|t| t.id)
|
||||
/// The track parameter `k` names: a track id, or "V1" / "A2" (kind and 1-based number).
|
||||
/// `Ok(None)` when the parameter is absent. Naming a track the active sequence does not have is
|
||||
/// an error (bad parameters of `cmd`): callers fall back to a default track only when no track
|
||||
/// was asked for.
|
||||
pub(crate) fn track_p(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<Option<TrackId>> {
|
||||
let v = match p.get(k) {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let by_name = |name: &str| {
|
||||
let mut rest = name.chars();
|
||||
let tracks = match rest.next()? {
|
||||
'v' | 'V' => &seq.video_tracks,
|
||||
'a' | 'A' => &seq.audio_tracks,
|
||||
_ => return None,
|
||||
};
|
||||
let i: usize = rest.as_str().parse().ok()?;
|
||||
tracks.get(i.checked_sub(1)?).map(|t| t.id)
|
||||
};
|
||||
let found = match v.as_u64() {
|
||||
Some(id) => seq.track(TrackId(id)).map(|t| t.id),
|
||||
None => v.as_str().and_then(by_name),
|
||||
};
|
||||
let named = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
|
||||
found.map(Some).ok_or_else(|| bad(cmd, format!("no track {named} in this sequence (`{k}`)")))
|
||||
}
|
||||
pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
|
||||
u64_p(p, k).map(ItemId)
|
||||
}
|
||||
|
||||
/// `timeline.move`: move the listed clips to (track, time). With Linked Selection on (or
|
||||
/// `linked: true`) the linked partners of a listed clip that are not listed themselves follow by
|
||||
/// the same time offset on their own tracks, so picture and sound stay in sync; a partner on a
|
||||
/// locked track, or one whose position would not change, stays as it is. Nothing moves before the sequence start: when a clip would,
|
||||
/// every clip of the call lands later by the same amount, so the spacing asked for is kept.
|
||||
fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "timeline.move";
|
||||
let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new();
|
||||
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() {
|
||||
let track = track_p(s, m, "track", CMD)?;
|
||||
if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) {
|
||||
listed.push((clip, track, Tick(time)));
|
||||
}
|
||||
}
|
||||
if listed.is_empty() {
|
||||
return Err(bad(CMD, "need `moves`"));
|
||||
}
|
||||
let follow = bool_p(p, "linked").unwrap_or(s.state.linked_selection);
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
// (clip, destination track, new start, where a partner starts now). `time` is any i64: the
|
||||
// sums are done in i128, where a few i64 values cannot overflow, and checked on the way back.
|
||||
let mut plan: Vec<(ClipId, TrackId, i128, Option<i128>)> = Vec::new();
|
||||
for &(clip, track, time) in &listed {
|
||||
plan.push((clip, track, i128::from(time.0), None));
|
||||
let Some((_, it)) = q.find_item(clip) else { continue }; // `edit::move_items` reports it
|
||||
let Some(link) = it.link.filter(|_| follow) else { continue };
|
||||
let offset = i128::from(time.0) - i128::from(it.start.0);
|
||||
for t in q.all_tracks().filter(|t| !t.locked) {
|
||||
for partner in t.items.iter().filter(|i| i.link == Some(link)) {
|
||||
if !listed.iter().any(|m| m.0 == partner.id) && !plan.iter().any(|m| m.0 == partner.id) {
|
||||
plan.push((partner.id, t.id, i128::from(partner.start.0) + offset, Some(i128::from(partner.start.0))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let late = plan.iter().map(|m| -m.2).max().unwrap_or(0).max(0);
|
||||
let mut moves: Vec<(ClipId, TrackId, Tick)> = Vec::with_capacity(plan.len());
|
||||
for (clip, track, start, was) in plan {
|
||||
// a partner that ends up where it is does not move (its listed clip only changed track):
|
||||
// re-placing it would drop the transitions at its edges
|
||||
if was == Some(start + late) {
|
||||
continue;
|
||||
}
|
||||
let start = i64::try_from(start + late).ok().map(Tick).filter(|t| *t <= Tick::MAX).ok_or_else(|| bad(CMD, "`time` is out of range"))?;
|
||||
moves.push((clip, track, start));
|
||||
}
|
||||
let ins = bool_p(p, "insert").unwrap_or(false);
|
||||
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
|
||||
Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Expand a clip selection with linked partners (when linked selection is on).
|
||||
pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
|
||||
let Some(seq) = s.active_sequence() else { return clips.to_vec() };
|
||||
@@ -737,9 +829,15 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(out)
|
||||
}),
|
||||
cmd!("file.exportInterchange", "Export Interchange", [], None, r#"{"format":"edl|xml|fcpxml|otio","path":str,"sequence":id?}"#, has_seq, |s, p| {
|
||||
crate::interchange::export(s, p)
|
||||
}),
|
||||
cmd!(
|
||||
"file.exportInterchange",
|
||||
"Export Interchange",
|
||||
[],
|
||||
None,
|
||||
r#"{"format":"edl|xml|fcpxml|otio|aaf|omf"=xml,"path":str,"sequence":id?}"#,
|
||||
has_seq,
|
||||
crate::interchange::export
|
||||
),
|
||||
cmd!("file.exportEdl", "EDL…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
|
||||
let mut q = p.clone();
|
||||
q["format"] = json!("edl");
|
||||
@@ -940,10 +1038,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
let sel = with_links(s, &clips_p(s, p));
|
||||
s.edit_sequence("Ripple Delete", |q, _, st| {
|
||||
let mut spans: Vec<TimeRange> = sel.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.range())).collect();
|
||||
spans.sort_by_key(|r| (r.start, r.duration));
|
||||
spans.dedup();
|
||||
edit::ripple_delete_items(q, &sel)?;
|
||||
let spans = edit::ripple_delete_items(q, &sel)?;
|
||||
if st.ripple_sequence_markers {
|
||||
for r in spans.iter().rev() {
|
||||
crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration);
|
||||
@@ -1034,8 +1129,26 @@ fn build() -> Vec<CommandSpec> {
|
||||
};
|
||||
let sel = with_links(s, &clips_p(s, p));
|
||||
s.edit_sequence("Speed/Duration", |q, ctx, _| {
|
||||
// a clip and its linked partners on other tracks change as one edit, so a ripple
|
||||
// moves the later clips once
|
||||
let place = |q: &filmcraft_project::Sequence, c: ClipId| q.find_item(c).map(|(t, i)| (t, i.link));
|
||||
let mut groups: Vec<Vec<ClipId>> = Vec::new();
|
||||
for c in &sel {
|
||||
// a clip that is not there goes on alone, so the edit fails with "no such item"
|
||||
let Some((track, link)) = place(q, *c) else {
|
||||
groups.push(vec![*c]);
|
||||
continue;
|
||||
};
|
||||
let joins = |g: &Vec<ClipId>| link.is_some() && g.iter().all(|o| place(q, *o).is_some_and(|(t, l)| l == link && t != track));
|
||||
match groups.iter_mut().find(|g| joins(g)) {
|
||||
Some(g) => g.push(*c),
|
||||
None => groups.push(vec![*c]),
|
||||
}
|
||||
}
|
||||
for group in &groups {
|
||||
edit::set_speed_group(q, group, speed, reverse, ripple, ctx)?;
|
||||
}
|
||||
for c in &sel {
|
||||
edit::set_speed(q, *c, speed, reverse, ripple, ctx)?;
|
||||
if let (Some(m), Some((_, it))) = (interp, q.find_item_mut(*c)) {
|
||||
it.time_interpolation = m;
|
||||
}
|
||||
@@ -1114,7 +1227,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Audio Gain…",
|
||||
["Clip", "Audio Options"],
|
||||
Some("G"),
|
||||
r#"{"mode":"set|adjust|normalizeMax|normalizeAll"?,"db":f64,"relative":bool?}"#,
|
||||
r#"{"clips":[id]?,"mode":"set|adjust|normalizeMax|normalizeAll"?,"db":f64,"relative":bool?}"#,
|
||||
has_selection,
|
||||
|s, p| crate::mixer::audio_gain(s, p)
|
||||
),
|
||||
@@ -1208,7 +1321,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
set_transition
|
||||
),
|
||||
cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id,"time":ticks}"#, has_seq, |s, p| {
|
||||
let tr = track_p(s, p, "track").ok_or_else(|| bad("sequence.closeGap", "need `track`"))?;
|
||||
let tr = track_p(s, p, "track", "sequence.closeGap")?.ok_or_else(|| bad("sequence.closeGap", "need `track`"))?;
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
s.edit_sequence("Ripple Delete", |q, _, st| {
|
||||
let before: Vec<(ClipId, Tick)> = q.track(tr).map(|x| x.items.iter().map(|i| (i.id, i.start)).collect()).unwrap_or_default();
|
||||
@@ -1252,7 +1365,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("sequence.deleteTrack", "Delete Track", [], None, r#"{"track":"V3"|id}"#, has_seq, |s, p| {
|
||||
let tr = track_p(s, p, "track").ok_or_else(|| bad("sequence.deleteTrack", "need `track`"))?;
|
||||
let tr = track_p(s, p, "track", "sequence.deleteTrack")?.ok_or_else(|| bad("sequence.deleteTrack", "need `track`"))?;
|
||||
s.edit_sequence("Delete Track", |q, _, _| {
|
||||
q.video_tracks.retain(|t| t.id != tr);
|
||||
q.audio_tracks.retain(|t| t.id != tr);
|
||||
@@ -1559,15 +1672,30 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Place Clip",
|
||||
[],
|
||||
None,
|
||||
r#"{"item":id,"track":"V1"|id?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?}"#,
|
||||
r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?;
|
||||
let at = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
let tg = s.targeting();
|
||||
let v = track_p(s, p, "track").or(tg.video_dest);
|
||||
let a = track_p(s, p, "audioTrack").or(tg.audio_dest);
|
||||
let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?;
|
||||
let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t));
|
||||
// `track` is where the picture goes and `audioTrack` where the sound goes. A caller that
|
||||
// names an audio track as `track` asks for the sound alone, on that track.
|
||||
let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) {
|
||||
(Some(t), Some(_)) if is_audio(t) => {
|
||||
return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once"));
|
||||
}
|
||||
(_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")),
|
||||
(Some(t), None) if is_audio(t) && !pi.has_audio() => {
|
||||
return Err(bad("timeline.place", "`track` names an audio track and this item has no sound"));
|
||||
}
|
||||
(Some(t), None) if is_audio(t) => (None, Some(t)),
|
||||
(Some(_), None) if !pi.has_video() => {
|
||||
return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track"));
|
||||
}
|
||||
(v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)),
|
||||
};
|
||||
let full = match &pi.kind {
|
||||
// Settings ▸ Timeline ▸ Still Image Default Duration
|
||||
ItemKind::Media(m)
|
||||
@@ -1616,19 +1744,15 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
}),
|
||||
cmd!("timeline.move", "Move Clips", [], None, r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool}"#, has_seq, |s, p| {
|
||||
let moves: Vec<(ClipId, TrackId, Tick)> = p
|
||||
.get("moves")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|m| Some((clip_p(m, "clip")?, track_p(s, m, "track")?, Tick(m.get("time")?.as_i64()?)))).collect())
|
||||
.unwrap_or_default();
|
||||
if moves.is_empty() {
|
||||
return Err(bad("timeline.move", "need `moves`"));
|
||||
}
|
||||
let ins = bool_p(p, "insert").unwrap_or(false);
|
||||
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!(
|
||||
"timeline.move",
|
||||
"Move Clips",
|
||||
[],
|
||||
None,
|
||||
r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?}"#,
|
||||
has_seq,
|
||||
move_clips
|
||||
),
|
||||
cmd!(
|
||||
"timeline.trim",
|
||||
"Trim Edit",
|
||||
@@ -1830,7 +1954,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("timeline.razor", "Razor", [], None, r#"{"time":ticks,"track":"V1"|id?,"clip":id?}"#, has_seq, |s, p| {
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
let tr = track_p(s, p, "track");
|
||||
let tr = track_p(s, p, "track", "timeline.razor")?;
|
||||
let clip = clip_p(p, "clip");
|
||||
let n = if let Some(c) = clip {
|
||||
let clips = with_links(s, &[c]);
|
||||
@@ -1849,7 +1973,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let tr = track_p(s, p, "track").ok_or_else(|| bad("timeline.setTrack", "need `track`"))?;
|
||||
let tr = track_p(s, p, "track", "timeline.setTrack")?.ok_or_else(|| bad("timeline.setTrack", "need `track`"))?;
|
||||
let p = p.clone();
|
||||
s.edit_sequence("Track Settings", |q, _, _| {
|
||||
let t = q.track_mut(tr).ok_or(filmcraft_edit::EditError::NoTrack(tr))?;
|
||||
@@ -1883,7 +2007,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("timeline.setTargeting", "Track Targeting", [], None, r#"{"track":"V1"|id,"targeted":bool?,"sourcePatch":bool?}"#, has_seq, |s, p| {
|
||||
let tr = track_p(s, p, "track").ok_or_else(|| bad("timeline.setTargeting", "need `track`"))?;
|
||||
let tr = track_p(s, p, "track", "timeline.setTargeting")?.ok_or_else(|| bad("timeline.setTargeting", "need `track`"))?;
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let mut tg = s.targeting();
|
||||
let is_video = s.active_sequence().is_some_and(|q| q.video_tracks.iter().any(|t| t.id == tr));
|
||||
@@ -2714,7 +2838,8 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
|
||||
"id": t.id.0, "name": t.name, "locked": t.locked, "syncLock": t.sync_lock, "enabled": t.enabled, "muted": t.muted, "solo": t.solo,
|
||||
"items": t.items.iter().map(|i| json!({
|
||||
"clip": i.id.0, "item": i.item.0, "name": i.name, "start": i.start.0, "duration": i.duration.0,
|
||||
"startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed,
|
||||
"startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed, "reverse": i.reverse,
|
||||
"end": i.end().0, "endFrame": rate.frame_at(i.end()), "sourceOut": i.source_out().0, "gainDb": i.gain_db,
|
||||
"enabled": i.enabled, "link": i.link, "label": i.label.name(),
|
||||
"effects": i.effects.iter().map(|e| json!({"effect": e.effect, "enabled": e.enabled, "masks": e.masks.len(), "params": e.params.iter().map(|(k, p)| (k.clone(), json!({"value": format!("{:?}", p.value), "keyframes": p.keyframes.len()}))).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
@@ -2731,7 +2856,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
|
||||
"in": q.mark_in.map(|t| t.0),
|
||||
"out": q.mark_out.map(|t| t.0),
|
||||
"split": q.split,
|
||||
"markers": q.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "duration": m.duration.0, "kind": format!("{:?}", m.kind), "name": m.name, "color": m.color.name()})).collect::<Vec<_>>(),
|
||||
"markers": q.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "duration": m.duration.0, "kind": format!("{:?}", m.kind), "name": m.name, "comment": m.comment, "color": m.color.name()})).collect::<Vec<_>>(),
|
||||
"video": q.video_tracks.iter().map(tr).collect::<Vec<_>>(),
|
||||
"audio": q.audio_tracks.iter().map(tr).collect::<Vec<_>>(),
|
||||
"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>(),
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! A caller that names its targets in the parameters (`clips`, `clip`, `item`, `items`) does not
|
||||
//! need a selection: enablement is checked against the named targets. Without such parameters
|
||||
//! (the menus, shortcuts and `engine.commands`) enablement is the selection's, as before.
|
||||
|
||||
use filmcraft_project::{ClipId, ItemId, TrackItem};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.state.selection.clear();
|
||||
s.state.project_selection.clear();
|
||||
s
|
||||
}
|
||||
|
||||
fn item_named(s: &Session, name: &str) -> ItemId {
|
||||
s.project.items.values().find(|i| i.name == name).map(|i| i.id).unwrap_or_else(|| panic!("no item {name}"))
|
||||
}
|
||||
|
||||
fn v1(s: &Session) -> Vec<TrackItem> {
|
||||
s.active_sequence().unwrap().video_tracks[0].items.clone()
|
||||
}
|
||||
|
||||
fn a1(s: &Session) -> Vec<TrackItem> {
|
||||
s.active_sequence().unwrap().audio_tracks[0].items.clone()
|
||||
}
|
||||
|
||||
fn clip(s: &Session, id: ClipId) -> Option<TrackItem> {
|
||||
s.active_sequence().unwrap().find_item(id).map(|(_, i)| i.clone())
|
||||
}
|
||||
|
||||
fn disabled(r: crate::Result<serde_json::Value>) -> String {
|
||||
match r {
|
||||
Err(EngineError::Disabled(_, why)) => why,
|
||||
other => panic!("expected a disabled command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_from_bin_takes_an_explicit_item_and_clips_without_any_selection() {
|
||||
let mut s = demo();
|
||||
let c = v1(&s)[0].clone();
|
||||
let dunes = item_named(&s, "Desert_Dunes.mp4");
|
||||
assert_ne!(c.item, dunes);
|
||||
// nothing named, nothing selected: disabled, for the menus and for a caller alike
|
||||
assert!(!s.is_enabled("clip.replaceFromBin"));
|
||||
assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({}))), "no clips selected");
|
||||
// the clip is named but the replacement is not: still the Project panel's call
|
||||
assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [c.id.0]}))), "select a clip in the Project panel");
|
||||
// both named: runs with no timeline and no Project-panel selection
|
||||
s.execute("clip.replaceFromBin", json!({"clips": [c.id.0], "item": dunes.0})).unwrap();
|
||||
assert_eq!(clip(&s, c.id).unwrap().item, dunes);
|
||||
assert!(s.state.selection.is_empty() && s.state.project_selection.is_empty(), "the selections are left as they were");
|
||||
assert!(!s.is_enabled("clip.replaceFromBin"), "menu enablement still follows the selection");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, c.id).unwrap().item, c.item);
|
||||
// a timeline selection plus an explicit item (no Project-panel selection)
|
||||
s.execute("timeline.select", json!({"clips": [c.id.0]})).unwrap();
|
||||
s.execute("clip.replaceFromBin", json!({"item": dunes.0})).unwrap();
|
||||
assert_eq!(clip(&s, c.id).unwrap().item, dunes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_commands_take_explicit_clips_without_a_selection() {
|
||||
let mut s = demo();
|
||||
let (a, b) = (v1(&s)[0].clone(), v1(&s)[1].clone());
|
||||
for id in ["edit.clear", "edit.rippleDelete", "clip.audioGain", "edit.pasteAttributes", "clip.enable"] {
|
||||
assert!(!s.is_enabled(id), "{id} is disabled without a selection");
|
||||
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id} with no parameters stays disabled");
|
||||
}
|
||||
// Clear
|
||||
s.execute("edit.clear", json!({"clips": [a.id.0]})).unwrap();
|
||||
assert!(clip(&s, a.id).is_none() && clip(&s, b.id).is_some());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(clip(&s, a.id).is_some());
|
||||
// Ripple Delete
|
||||
let a_and_its_audio: Vec<u64> = s
|
||||
.active_sequence()
|
||||
.unwrap()
|
||||
.all_tracks()
|
||||
.flat_map(|t| &t.items)
|
||||
.filter(|i| i.id == a.id || (i.link.is_some() && i.link == a.link))
|
||||
.map(|i| i.id.0)
|
||||
.collect();
|
||||
s.execute("timeline.setTrack", json!({"track": "A2", "syncLock": false})).unwrap(); // the music on A2 would block the ripple
|
||||
s.execute("edit.rippleDelete", json!({"clips": a_and_its_audio})).unwrap();
|
||||
assert!(clip(&s, a.id).is_none());
|
||||
assert_eq!(clip(&s, b.id).unwrap().start, a.start, "the next clip closes the gap");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Audio Gain
|
||||
let au = a1(&s)[0].clone();
|
||||
s.execute("clip.audioGain", json!({"clips": [au.id.0], "mode": "set", "db": -6.0})).unwrap();
|
||||
assert_eq!(clip(&s, au.id).unwrap().gain_db, -6.0);
|
||||
// Enable
|
||||
s.execute("clip.enable", json!({"clips": [a.id.0]})).unwrap();
|
||||
assert!(!clip(&s, a.id).unwrap().enabled);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(clip(&s, a.id).unwrap().enabled);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(!clip(&s, a.id).unwrap().enabled);
|
||||
// `clip.enable` documents `clips` only: a key it does not document is not a named target
|
||||
assert_eq!(disabled(s.execute("clip.enable", json!({"clip": a.id.0}))), "no clips selected");
|
||||
// Paste Attributes: naming the clips lifts only the selection condition, not the clipboard one
|
||||
assert_eq!(disabled(s.execute("edit.pasteAttributes", json!({"clips": [b.id.0]}))), "copy a clip first");
|
||||
s.execute("timeline.select", json!({"clips": [a.id.0]})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.selection.clear();
|
||||
s.execute("edit.pasteAttributes", json!({"clips": [b.id.0]})).unwrap();
|
||||
assert!(s.state.selection.is_empty(), "the selection is left as it was");
|
||||
for id in ["edit.clear", "edit.rippleDelete", "clip.audioGain", "edit.pasteAttributes"] {
|
||||
assert!(!s.is_enabled(id), "menu enablement of {id} still follows the selection");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_nothing_usable_does_not_enable_a_command() {
|
||||
let mut s = demo();
|
||||
let before = s.project.clone();
|
||||
// no ids, ids of no clip, the wrong type, and a key the command does not take
|
||||
for p in [json!({"clips": []}), json!({"clips": [987_654_321u64]}), json!({"clips": "all"}), json!({"clips": [-1, 1.5, null]}), json!({"items": [1]})] {
|
||||
assert_eq!(disabled(s.execute("edit.rippleDelete", p.clone())), "nothing selected", "{p}");
|
||||
}
|
||||
assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [v1(&s)[0].id.0], "item": 987_654_321u64}))), "select a clip in the Project panel");
|
||||
// `edit.cut` takes no `clips`: it works on the selection only
|
||||
assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips selected");
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.project), "nothing was edited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_and_speed_take_explicit_clips_without_a_selection() {
|
||||
let mut s = demo();
|
||||
let (v, au) = (v1(&s)[0].clone(), a1(&s)[0].clone());
|
||||
for id in ["clip.link", "clip.speedDuration"] {
|
||||
assert!(!s.is_enabled(id));
|
||||
assert_eq!(disabled(s.execute(id, json!({}))), "no clips selected");
|
||||
}
|
||||
// Link toggles the named pair
|
||||
let was = v.link.is_some() && v.link == au.link;
|
||||
let r = s.execute("clip.link", json!({"clips": [v.id.0, au.id.0]})).unwrap();
|
||||
assert_eq!(r["linked"], !was);
|
||||
assert_eq!(clip(&s, v.id).unwrap().link.is_some(), !was);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, v.id).unwrap().link, v.link);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, v.id).unwrap().link.is_some(), !was);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Speed/Duration on the last clip of V1 (nothing after it to run into)
|
||||
let last = v1(&s).last().unwrap().clone();
|
||||
s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200})).unwrap();
|
||||
let fast = clip(&s, last.id).unwrap();
|
||||
assert!(fast.duration < last.duration, "{:?} -> {:?}", last.duration, fast.duration);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, last.id).unwrap().duration, last.duration);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, last.id).unwrap().duration, fast.duration);
|
||||
assert!(s.state.selection.is_empty() && !s.is_enabled("clip.speedDuration"), "menu enablement still follows the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_targets_lifts_only_the_selection_condition() {
|
||||
// no sequence: named clips cannot stand in for one
|
||||
let mut empty = Session::default();
|
||||
assert_eq!(disabled(empty.execute("edit.clear", json!({"clips": [1]}))), "no sequence is open");
|
||||
assert_eq!(disabled(empty.execute("clip.speedDuration", json!({"clips": [1], "speed": 50}))), "no sequence is open");
|
||||
|
||||
let mut s = demo();
|
||||
let a = v1(&s)[0].clone();
|
||||
// the wrong kind of target: a video clip is not a graphic
|
||||
assert_eq!(disabled(s.execute("graphics.setText", json!({"clip": a.id.0, "text": "x"}))), "select a graphic clip");
|
||||
// a locked track is still the edit's own business: named or selected, the clip stays
|
||||
s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap();
|
||||
let _ = s.execute("edit.clear", json!({"clips": [a.id.0]}));
|
||||
assert!(clip(&s, a.id).is_some(), "a clip on a locked track is not removed");
|
||||
}
|
||||
@@ -129,6 +129,33 @@ fn graphic_renders_in_the_program_and_survives_save() {
|
||||
assert!(fonts.as_array().unwrap().iter().any(|f| f["family"] == "Inter"));
|
||||
}
|
||||
|
||||
/// `file.exportInterchange` wrote a title out of the document without saying so: the command's
|
||||
/// report names every graphic clip a format cannot carry.
|
||||
#[test]
|
||||
fn interchange_export_reports_the_title_it_cannot_carry() {
|
||||
let d = std::env::temp_dir().join(format!("fc-title-report-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newText", json!({"text": "Opening line", "time": 0, "seconds": 2.0})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let (track, it) = s.active_sequence().unwrap().find_item(clip).map(|(t, i)| (t, i.clone())).unwrap();
|
||||
let frame = s.sequence_rate().frame_at(it.start);
|
||||
let want = format!("graphic clip \"{}\" at frame {frame}", it.name);
|
||||
for format in ["xml", "fcpxml", "otio", "aaf"] {
|
||||
let path = d.join(format!("cut.{format}")).to_string_lossy().to_string();
|
||||
let r = s.execute("file.exportInterchange", json!({"format": format, "path": path})).unwrap();
|
||||
let named = r["report"].as_array().unwrap().iter().filter(|l| l.as_str().is_some_and(|l| l.starts_with(&want))).count();
|
||||
assert_eq!(named, 1, "{format}: {}", r["report"]);
|
||||
}
|
||||
// an EDL holds one video track: the report names the title when its track is the one written
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_ne!(q.video_tracks[0].id, track, "the title sits above the footage");
|
||||
let path = d.join("cut.edl").to_string_lossy().to_string();
|
||||
let r = s.execute("file.exportInterchange", json!({"format": "edl", "path": path})).unwrap();
|
||||
assert!(r["report"].to_string().contains("one video track"), "{}", r["report"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
fn quad_box(s: &mut Session, clip: u64, layer: usize) -> [f64; 4] {
|
||||
let l = s.execute("graphics.list", json!({"clip": clip})).unwrap();
|
||||
let q = l["layers"][layer]["quad"].as_array().unwrap().clone();
|
||||
|
||||
@@ -20,6 +20,10 @@ fn report_json(r: &filmcraft_interchange::Report) -> Vec<String> {
|
||||
r.entries.iter().map(|e| if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() }).collect()
|
||||
}
|
||||
|
||||
fn is_file_media(item: &filmcraft_project::ProjectItem) -> bool {
|
||||
matches!(&item.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::File { .. }))
|
||||
}
|
||||
|
||||
/// Import a document: merge its bins, media and sequences into the project (one undo step), then
|
||||
/// link each media file that exists on disk. Returns the new sequences.
|
||||
pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result<Value> {
|
||||
@@ -46,7 +50,15 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
||||
};
|
||||
let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();
|
||||
let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());
|
||||
let seqs = s.edit(&format!("Import {name}"), |proj, _| Ok(filmcraft_interchange::merge_into(proj, fragment, None)))?;
|
||||
let seqs = s.edit(&format!("Import {name}"), |proj, _| {
|
||||
let seqs = filmcraft_interchange::merge_into(proj, fragment, None);
|
||||
// A document that sets only some Motion parameters leaves the others at their "auto"
|
||||
// (NaN) defaults. Resolve them as placing a clip does, for every format. A media file's
|
||||
// picture size is only what the document says so far (an EDL says nothing: the importer
|
||||
// assumes the sequence size), so its clips' anchors wait for Link Media below.
|
||||
proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source));
|
||||
Ok(seqs)
|
||||
})?;
|
||||
// Link media: probe each new file-backed item that exists.
|
||||
let new_media: Vec<(ItemId, String)> = s
|
||||
.project
|
||||
@@ -85,6 +97,8 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
||||
filmcraft_interchange::rebase_source_timecode(proj, id, tc, rate);
|
||||
}
|
||||
}
|
||||
// the picture size is now the file's own: centre the anchors of its clips in it
|
||||
proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| source.id == id);
|
||||
Ok(())
|
||||
})?;
|
||||
s.media.insert_file(id, &mpath, src);
|
||||
@@ -108,15 +122,17 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
||||
}))
|
||||
}
|
||||
|
||||
/// `file.exportInterchange {format: "edl"|"xml"|"fcpxml"|"otio", path, sequence?}`
|
||||
/// `file.exportInterchange {format: "edl"|"xml"|"fcpxml"|"otio"|"aaf"|"omf" = "xml", path, sequence?}`
|
||||
pub fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let format = match p.get("format").and_then(Value::as_str).unwrap_or("xml") {
|
||||
"edl" => Format::Edl,
|
||||
"fcpxml" => Format::Fcpxml,
|
||||
"otio" => Format::Otio,
|
||||
"aaf" => Format::Aaf,
|
||||
"omf" => Format::Omf,
|
||||
_ => Format::Fcp7Xml,
|
||||
// FCP7 XML only when no format is asked for: a format we don't know is an error, not XML.
|
||||
// The names are the documented ones (each format's extension, in either case) and nothing
|
||||
// else, so the error can list exactly what is accepted.
|
||||
let format = match p.get("format") {
|
||||
None | Some(Value::Null) => Format::Fcp7Xml,
|
||||
Some(v) => v.as_str().and_then(|name| Format::ALL.into_iter().find(|f| f.extension().eq_ignore_ascii_case(name))).ok_or_else(|| {
|
||||
let names: Vec<&str> = Format::ALL.iter().map(|f| f.extension()).collect();
|
||||
crate::commands::bad("file.exportInterchange", format!("unknown format {v}: use one of {}", names.join(", ")))
|
||||
})?,
|
||||
};
|
||||
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::Other("need `path`".into()))?.to_string();
|
||||
let seq = p.get("sequence").and_then(Value::as_u64).map(ItemId).or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Interchange imports and the "auto" (NaN) point defaults of the effects they add: the points
|
||||
//! are resolved as placing a clip resolves them, the anchor once the media's real size is known,
|
||||
//! and a project saved after an import opens again.
|
||||
|
||||
use filmcraft_geom::Vec2;
|
||||
use filmcraft_media::DemoScene;
|
||||
use filmcraft_project::{ItemId, ParamValue, Project, TrackItem};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::media_test_util::{make_movie, tmp_dir};
|
||||
|
||||
const RATE: &str = "<rate><timebase>24</timebase><ntsc>FALSE</ntsc></rate>";
|
||||
|
||||
/// A 1920×1080 FCP7 XML sequence with one clip of `media/shot.mov` (declared 3840×2160) whose
|
||||
/// Basic Motion filter sets only `scale`.
|
||||
fn xml_with_a_scale_only_motion_filter() -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4">
|
||||
<sequence id="sequence-1">
|
||||
<name>Cut</name>
|
||||
<duration>24</duration>
|
||||
{RATE}
|
||||
<media>
|
||||
<video>
|
||||
<format><samplecharacteristics>{RATE}<width>1920</width><height>1080</height></samplecharacteristics></format>
|
||||
<track>
|
||||
<clipitem id="clipitem-1">
|
||||
<name>shot</name>
|
||||
{RATE}
|
||||
<start>0</start><end>24</end><in>0</in><out>24</out>
|
||||
<file id="file-1">
|
||||
<name>shot.mov</name>
|
||||
<pathurl>media/shot.mov</pathurl>
|
||||
{RATE}
|
||||
<duration>24</duration>
|
||||
<media><video><samplecharacteristics><width>3840</width><height>2160</height></samplecharacteristics></video></media>
|
||||
</file>
|
||||
<filter><effect>
|
||||
<name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
|
||||
<parameter><parameterid>scale</parameterid><name>Scale</name><value>50</value></parameter>
|
||||
</effect></filter>
|
||||
</clipitem>
|
||||
</track>
|
||||
</video>
|
||||
</media>
|
||||
</sequence>
|
||||
</xmeml>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn first_clip(s: &Session, seq: ItemId) -> TrackItem {
|
||||
s.project.sequence(seq).unwrap().video_tracks[0].items[0].clone()
|
||||
}
|
||||
|
||||
fn motion_point(c: &TrackItem, k: &str) -> Vec2 {
|
||||
c.effect("motion").unwrap().vec2_at(k, Tick::ZERO)
|
||||
}
|
||||
|
||||
/// Every effect point parameter of every clip that is still "auto".
|
||||
fn auto_points(p: &Project) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for q in p.sequences().filter_map(|i| i.as_sequence()) {
|
||||
for c in q.all_tracks().flat_map(|t| &t.items) {
|
||||
for e in &c.effects {
|
||||
for (k, v) in &e.params {
|
||||
if matches!(&v.value, ParamValue::Vec2(v) if v.x.is_nan() || v.y.is_nan()) {
|
||||
out.push(format!("{}.{k}", e.effect));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, doc: &std::path::Path) -> (serde_json::Value, ItemId) {
|
||||
let r = s.execute("file.import", json!({"paths": [doc.to_string_lossy()]})).unwrap();
|
||||
let seq = ItemId(r["sequences"][0].as_u64().unwrap_or_else(|| panic!("no sequence in {r}")));
|
||||
(r["documents"][0].clone(), seq)
|
||||
}
|
||||
|
||||
/// A project saved after such an import held `null` points and could not be opened again
|
||||
/// ("project file is damaged: invalid type: null, expected f64").
|
||||
#[test]
|
||||
fn a_project_saved_after_an_interchange_import_opens_again() {
|
||||
let d = tmp_dir("ix-auto-offline");
|
||||
let doc = d.join("cut.xml");
|
||||
std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let before = s.project.clone();
|
||||
// the media file is not there: its real size is not known
|
||||
let (r, seq) = import(&mut s, &doc);
|
||||
assert_eq!((r["linkedMedia"].as_u64(), r["offlineMedia"].as_array().map(Vec::len)), (Some(0), Some(1)), "{r}");
|
||||
let clip = first_clip(&s, seq);
|
||||
assert_eq!(clip.effect("motion").unwrap().f64_at("scale", Tick::ZERO), 50.0);
|
||||
assert_eq!(motion_point(&clip, "position"), Vec2::new(960.0, 540.0), "the frame centre, as placing a clip resolves it");
|
||||
assert!(motion_point(&clip, "anchor").x.is_nan(), "the anchor waits for the media's real size");
|
||||
assert_eq!(auto_points(&s.project), ["motion.anchor"]);
|
||||
// the saved file opens again and holds the same project (NaN != NaN, so compare what is written)
|
||||
let path = d.join("p.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!(filmcraft_format::encode(&t.project, false), filmcraft_format::encode(&s.project, false));
|
||||
assert_eq!(auto_points(&t.project), ["motion.anchor"]);
|
||||
assert_eq!(motion_point(&first_clip(&t, seq), "position"), Vec2::new(960.0, 540.0));
|
||||
// without a link step the import is one undo step
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(auto_points(&s.project), ["motion.anchor"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// The anchor is centred in the file's real picture, not in the size the document declares
|
||||
/// (3840×2160 here) or the sequence's.
|
||||
#[test]
|
||||
fn a_linked_clip_gets_its_anchor_from_the_real_size_of_the_media() {
|
||||
let d = tmp_dir("ix-auto-linked");
|
||||
std::fs::create_dir_all(d.join("media")).unwrap();
|
||||
make_movie(&d.join("media/shot.mov"), DemoScene::OceanSunset, 320, 180, 24);
|
||||
let doc = d.join("cut.xml");
|
||||
std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let before = s.project.clone();
|
||||
let (r, seq) = import(&mut s, &doc);
|
||||
assert_eq!(r["linkedMedia"], 1, "{r}");
|
||||
let clip = first_clip(&s, seq);
|
||||
assert_eq!(s.project.source_size(clip.item), Some((320, 180)));
|
||||
assert_eq!((motion_point(&clip, "position"), motion_point(&clip, "anchor")), (Vec2::new(960.0, 540.0), Vec2::new(160.0, 90.0)));
|
||||
assert_eq!(auto_points(&s.project), Vec::<String>::new());
|
||||
// nothing is NaN, so the reopened project is equal
|
||||
let path = d.join("p.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.project, *s.project);
|
||||
// undo: Link Media takes the real size and the anchor back together, then the import itself
|
||||
let linked = s.project.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let clip = first_clip(&s, seq);
|
||||
assert_eq!(s.project.source_size(clip.item), Some((3840, 2160)), "the size the document declared");
|
||||
assert!(motion_point(&clip, "anchor").x.is_nan());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *linked);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// An EDL says nothing about the picture: the importer assumes the sequence size for the media
|
||||
/// until the file is probed. A clip smaller than the sequence must still be centred.
|
||||
#[test]
|
||||
fn an_edl_clip_is_centred_in_its_real_size_not_the_assumed_sequence_size() {
|
||||
let d = tmp_dir("ix-auto-edl");
|
||||
make_movie(&d.join("shot.mov"), DemoScene::OceanSunset, 320, 180, 24);
|
||||
let doc = d.join("cut.edl");
|
||||
let edl = "TITLE: Cut\nFCM: NON-DROP FRAME\n\n001 AX V C 00:00:00:00 00:00:01:00 01:00:00:00 01:00:01:00\n* FROM CLIP NAME: shot.mov\n* SOURCE FILE: shot.mov\n";
|
||||
std::fs::write(&doc, edl).unwrap();
|
||||
let mut s = Session::default();
|
||||
let (r, seq) = import(&mut s, &doc);
|
||||
assert_eq!(r["linkedMedia"], 1, "{r}");
|
||||
let st = s.project.sequence(seq).unwrap().settings.clone();
|
||||
assert_ne!((st.width, st.height), (320, 180), "the media is not the size of the sequence");
|
||||
let clip = first_clip(&s, seq);
|
||||
assert_eq!(motion_point(&clip, "position"), Vec2::new(st.width as f64 / 2.0, st.height as f64 / 2.0));
|
||||
assert_eq!(motion_point(&clip, "anchor"), Vec2::new(160.0, 90.0), "the centre of the 320×180 picture");
|
||||
assert_eq!(auto_points(&s.project), Vec::<String>::new());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Imported with the media missing, saved, reopened, and only then linked: Link Media gives the
|
||||
/// item its real size, and the anchors that waited for it become numbers.
|
||||
#[test]
|
||||
fn relinking_later_resolves_the_anchor_that_waited_for_the_real_size() {
|
||||
let d = tmp_dir("ix-auto-relink");
|
||||
let doc = d.join("cut.xml");
|
||||
std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let (_, seq) = import(&mut s, &doc);
|
||||
let path = d.join("p.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();
|
||||
let clip = first_clip(&t, seq);
|
||||
assert!(motion_point(&clip, "anchor").x.is_nan(), "still auto after the reopen");
|
||||
// the file turns up in another folder, smaller than the document said
|
||||
std::fs::create_dir_all(d.join("found")).unwrap();
|
||||
let found = d.join("found/shot.mov");
|
||||
make_movie(&found, DemoScene::OceanSunset, 320, 180, 24);
|
||||
let waiting = t.project.clone();
|
||||
let r = t.execute("media.relink", json!({"item": clip.item.0, "path": found.to_string_lossy(), "force": true})).unwrap();
|
||||
assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let clip = first_clip(&t, seq);
|
||||
assert_eq!(t.project.source_size(clip.item), Some((320, 180)));
|
||||
assert_eq!((motion_point(&clip, "position"), motion_point(&clip, "anchor")), (Vec2::new(960.0, 540.0), Vec2::new(160.0, 90.0)));
|
||||
assert_eq!(auto_points(&t.project), Vec::<String>::new());
|
||||
// one undo step takes the link and the anchor back together
|
||||
let linked = t.project.clone();
|
||||
t.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(filmcraft_format::encode(&t.project, false), filmcraft_format::encode(&waiting, false));
|
||||
assert_eq!(auto_points(&t.project), ["motion.anchor"]);
|
||||
t.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*t.project, *linked);
|
||||
// an anchor that is already a number is not touched by a later relink to another size
|
||||
std::fs::create_dir_all(d.join("other")).unwrap();
|
||||
let other = d.join("other/shot.mov");
|
||||
make_movie(&other, DemoScene::OceanSunset, 160, 90, 24);
|
||||
t.execute("media.relink", json!({"item": clip.item.0, "path": other.to_string_lossy(), "force": true})).unwrap();
|
||||
assert_eq!(motion_point(&first_clip(&t, seq), "anchor"), Vec2::new(160.0, 90.0));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -163,6 +163,11 @@ impl Services for FsServices {
|
||||
fn file_loader(&self) -> Option<filmcraft_media::sequence::FrameLoader> {
|
||||
Some(std::sync::Arc::new(|p: &str| std::fs::read(p)))
|
||||
}
|
||||
/// Media are read in place from the open file: a clip costs its index, not its size.
|
||||
#[cfg(any(unix, windows))]
|
||||
fn reader(&self, path: &str) -> Option<std::io::Result<filmcraft_media::SharedReader>> {
|
||||
Some(filmcraft_media::reader::FileReader::open(std::path::Path::new(path)).map(|r| std::sync::Arc::new(r) as filmcraft_media::SharedReader))
|
||||
}
|
||||
fn list_entries(&self, dir: &str) -> Option<std::io::Result<Vec<media_browser::DirEntry>>> {
|
||||
Some(media_browser::std_list_entries(dir))
|
||||
}
|
||||
@@ -595,7 +600,7 @@ impl Session {
|
||||
if self.exec_depth == 0 && spec.journal && self.trim_play.active() {
|
||||
self.settle_trim_playback(id);
|
||||
}
|
||||
if let Err(why) = (spec.enabled)(self) {
|
||||
if let Err(why) = self.enabled_for(spec, ¶ms) {
|
||||
let e = EngineError::Disabled(id.to_string(), why);
|
||||
if self.exec_depth == 0 {
|
||||
self.log.push(panels::Level::Warning, id, e.to_string());
|
||||
@@ -645,6 +650,33 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enablement of `spec` for one call. `enabled()` reads the selection, which is right for the
|
||||
/// menus and for a call without targets. A caller that names its targets in `params`, under a
|
||||
/// key the command documents (`clips` / `clip`: timeline clips, `items` / `item`: project
|
||||
/// items), is asked the same question about those targets instead: the check runs again with
|
||||
/// them standing in for the selection, so every other condition (an open sequence, a filled
|
||||
/// clipboard, the kind of item…) still applies. The selections are put back before returning.
|
||||
fn enabled_for(&mut self, spec: &CommandSpec, params: &Value) -> std::result::Result<(), String> {
|
||||
let by_selection = (spec.enabled)(self);
|
||||
if by_selection.is_ok() {
|
||||
return by_selection;
|
||||
}
|
||||
let clips = commands::named_clips(self, spec, params);
|
||||
let items = commands::named_items(self, spec, params);
|
||||
if clips.is_none() && items.is_none() {
|
||||
return by_selection;
|
||||
}
|
||||
let mut clips = clips.unwrap_or_else(|| self.state.selection.clone());
|
||||
let mut items = items.unwrap_or_else(|| self.state.project_selection.clone());
|
||||
std::mem::swap(&mut self.state.selection, &mut clips);
|
||||
std::mem::swap(&mut self.state.project_selection, &mut items);
|
||||
let by_params = (spec.enabled)(self);
|
||||
self.state.selection = clips;
|
||||
self.state.project_selection = items;
|
||||
by_params
|
||||
}
|
||||
|
||||
/// Whether the command can run on the current selection (what the menus show).
|
||||
pub fn is_enabled(&self, id: &str) -> bool {
|
||||
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
|
||||
}
|
||||
@@ -880,14 +912,20 @@ mod color_tests;
|
||||
#[cfg(test)]
|
||||
mod essential_sound_tests;
|
||||
#[cfg(test)]
|
||||
mod explicit_targets_tests;
|
||||
#[cfg(test)]
|
||||
mod export_tests;
|
||||
#[cfg(test)]
|
||||
mod file_tests;
|
||||
#[cfg(test)]
|
||||
mod image_sequence_tests;
|
||||
#[cfg(test)]
|
||||
mod interchange_auto_points_tests;
|
||||
#[cfg(test)]
|
||||
mod keyboard_tests;
|
||||
#[cfg(test)]
|
||||
mod linked_speed_tests;
|
||||
#[cfg(test)]
|
||||
mod masks_tests;
|
||||
#[cfg(test)]
|
||||
mod media_browser_tests;
|
||||
@@ -914,16 +952,26 @@ mod relink_tests;
|
||||
#[cfg(test)]
|
||||
mod remix_tests;
|
||||
#[cfg(test)]
|
||||
mod ripple_delete_tests;
|
||||
#[cfg(test)]
|
||||
mod scopes_tests;
|
||||
#[cfg(test)]
|
||||
mod sequence_inspect_tests;
|
||||
#[cfg(test)]
|
||||
mod sequence_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod settings_tests;
|
||||
#[cfg(test)]
|
||||
mod shortcuts_tests;
|
||||
#[cfg(test)]
|
||||
mod split_edit_ripple_trim_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod timeline_move_tests;
|
||||
#[cfg(test)]
|
||||
mod track_params_tests;
|
||||
#[cfg(test)]
|
||||
mod transcript_tests;
|
||||
#[cfg(test)]
|
||||
mod trim_tests;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! `clip.speedDuration` with `ripple` on a clip that has linked sound: the clip and its sound
|
||||
//! change as one edit, and the later clips move once.
|
||||
|
||||
use filmcraft_project::ClipId;
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
|
||||
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
|
||||
}
|
||||
|
||||
/// Three 48-frame clips in a row, pictures on V1 and their linked sounds on A1: [(picture, sound); 3].
|
||||
fn three_clips(s: &mut Session) -> [(ClipId, ClipId); 3] {
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "Speed", "video": 1, "audio": 1})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
for n in 0..3 {
|
||||
s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
|
||||
}
|
||||
let q = s.active_sequence().unwrap();
|
||||
[0, 1, 2].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
|
||||
}
|
||||
|
||||
/// Slower with ripple moved the later clips twice (once for the picture, once for its sound) and
|
||||
/// left a double gap.
|
||||
#[test]
|
||||
fn slower_with_ripple_moves_later_clips_once() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f48, f144, f192) = (f(48), f(144), f(192));
|
||||
let before = s.project.clone();
|
||||
s.execute("clip.speedDuration", json!({"clips": [picture2.0], "speed": 50, "ripple": true})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
assert_eq!((range(&s, picture2), range(&s, sound2)), ((f48, f144), (f48, f144)), "the clip and its sound are twice as long");
|
||||
assert_eq!((range(&s, picture3), range(&s, sound3)), ((f144, f192), (f144, f192)), "the next clip follows, once");
|
||||
assert_eq!(q.duration(), f192);
|
||||
let after = s.project.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before, "one undo step restores it");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *after);
|
||||
}
|
||||
|
||||
/// Faster with ripple was refused when more than one clip followed: the second ripple pulled the
|
||||
/// farther clips onto the nearer ones.
|
||||
#[test]
|
||||
fn faster_with_ripple_moves_later_clips_once() {
|
||||
let mut s = Session::default();
|
||||
let [(picture1, sound1), (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f0, f24, f72, f120) = (f(0), f(24), f(72), f(120));
|
||||
s.execute("clip.speedDuration", json!({"clips": [picture1.0], "speed": 200, "ripple": true})).unwrap();
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
for (picture, sound, at) in [(picture1, sound1, (f0, f24)), (picture2, sound2, (f24, f72)), (picture3, sound3, (f72, f120))] {
|
||||
assert_eq!((range(&s, picture), range(&s, sound)), (at, at));
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip that is not in the sequence is an error and nothing changes, also next to one that is.
|
||||
#[test]
|
||||
fn speed_on_a_missing_clip_is_an_error() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, _), _] = three_clips(&mut s);
|
||||
let before = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
assert!(s.execute("clip.speedDuration", json!({"clips": [999_999], "speed": 50, "ripple": true})).is_err());
|
||||
assert!(s.execute("clip.speedDuration", json!({"clips": [picture2.0, 999_999], "speed": 50, "ripple": true})).is_err());
|
||||
assert_eq!(*s.project, *before);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// Several clips in one call each ripple in turn, as before.
|
||||
#[test]
|
||||
fn speed_with_ripple_on_two_clips_ripples_each() {
|
||||
let mut s = Session::default();
|
||||
let [(picture1, sound1), (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f0, f24, f48, f96) = (f(0), f(24), f(48), f(96));
|
||||
s.execute("clip.speedDuration", json!({"clips": [picture1.0, picture2.0], "speed": 200, "ripple": true})).unwrap();
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
for (picture, sound, at) in [(picture1, sound1, (f0, f24)), (picture2, sound2, (f24, f48)), (picture3, sound3, (f48, f96))] {
|
||||
assert_eq!((range(&s, picture), range(&s, sound)), (at, at));
|
||||
}
|
||||
}
|
||||
|
||||
/// Without `ripple` nothing after the clip moves.
|
||||
#[test]
|
||||
fn speed_without_ripple_leaves_later_clips() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f48, f72, f96, f144) = (f(48), f(72), f(96), f(144));
|
||||
s.execute("clip.speedDuration", json!({"clips": [picture2.0], "speed": 200})).unwrap();
|
||||
assert_eq!((range(&s, picture2), range(&s, sound2)), ((f48, f72), (f48, f72)));
|
||||
assert_eq!((range(&s, picture3), range(&s, sound3)), ((f96, f144), (f96, f144)));
|
||||
}
|
||||
|
||||
/// Linked Selection off and both the picture and its sound named: still one edit, one ripple.
|
||||
#[test]
|
||||
fn speed_with_ripple_on_a_named_picture_and_sound() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f48, f144, f192) = (f(48), f(144), f(192));
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("clip.speedDuration", json!({"clips": [picture2.0, sound2.0], "speed": 50, "ripple": true})).unwrap();
|
||||
assert_eq!((range(&s, picture2), range(&s, sound2)), ((f48, f144), (f48, f144)));
|
||||
assert_eq!((range(&s, picture3), range(&s, sound3)), ((f144, f192), (f144, f192)));
|
||||
}
|
||||
|
||||
/// Two clips of one track that were linked to each other (Clip ▸ Link) ripple one after the
|
||||
/// other, as unlinked clips do.
|
||||
#[test]
|
||||
fn speed_with_ripple_on_linked_clips_of_one_track() {
|
||||
let mut s = Session::default();
|
||||
let [(picture1, _), (picture2, _), (picture3, _)] = three_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f0, f96, f192, f240) = (f(0), f(96), f(192), f(240));
|
||||
s.execute("timeline.setTrack", json!({"track": "A1", "syncLock": false})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [picture1.0, picture2.0]})).unwrap();
|
||||
// both are linked to their sounds, so the first call unlinks them and the second links the two
|
||||
s.execute("clip.link", json!({})).unwrap();
|
||||
assert_eq!(s.execute("clip.link", json!({})).unwrap()["linked"], true);
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
s.execute("clip.speedDuration", json!({"clips": [picture1.0], "speed": 50, "ripple": true})).unwrap();
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
assert_eq!((range(&s, picture1), range(&s, picture2), range(&s, picture3)), ((f0, f96), (f96, f192), (f192, f240)));
|
||||
}
|
||||
|
||||
/// A split edit: the sound runs 12 frames longer than its picture. Slower, the later clips move
|
||||
/// by the sound's change, so nothing lands on the sound and the next clip stays in sync.
|
||||
#[test]
|
||||
fn speed_with_ripple_on_a_split_edit() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f48, f144, f156, f168) = (f(48), f(144), f(156), f(168));
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": sound3.0, "edge": "in", "mode": "regular", "deltaFrames": 12})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": sound2.0, "edge": "out", "mode": "regular", "deltaFrames": 12})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
s.execute("clip.speedDuration", json!({"clips": [picture2.0], "speed": 50, "ripple": true})).unwrap();
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
assert_eq!((range(&s, picture2), range(&s, sound2)), ((f48, f144), (f48, f168)), "picture 48 to 96 frames, sound 60 to 120");
|
||||
assert_eq!((range(&s, picture3).0, range(&s, sound3).0), (f156, f168), "the next sound still starts 12 after its picture");
|
||||
}
|
||||
@@ -382,3 +382,47 @@ impl MediaSource for ProxySource {
|
||||
self.proxy.audio(start, frames, sample_rate)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A host whose whole-file read fails: opening must go through the reader.
|
||||
struct ReaderOnly(Arc<[u8]>);
|
||||
impl Services for ReaderOnly {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
Err(std::io::Error::other(format!("{path}: read whole")))
|
||||
}
|
||||
fn write_file(&self, _path: &str, _data: &[u8]) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn reader(&self, _path: &str) -> Option<std::io::Result<filmcraft_media::SharedReader>> {
|
||||
Some(Ok(Arc::new(filmcraft_media::reader::MemReader(self.0.clone()))))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_file_prefers_the_hosts_reader() {
|
||||
let wav = filmcraft_media::wav::write_wav16(&[0.0, 0.5, -0.5, 0.25], 2, 48_000);
|
||||
let s = MediaPool::default().open_file("a.wav", &ReaderOnly(wav.into())).unwrap();
|
||||
assert!(s.info().has_audio());
|
||||
}
|
||||
|
||||
/// The desktop host reads media in place instead of loading every clip into memory.
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn fs_services_open_media_through_a_file_reader() {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-pool-reader-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.wav");
|
||||
let wav = filmcraft_media::wav::write_wav16(&[0.0, 0.5, -0.5, 0.25], 2, 48_000);
|
||||
std::fs::write(&path, &wav).unwrap();
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let r = crate::FsServices.reader(&p).expect("a reader").unwrap();
|
||||
assert_eq!(r.len(), wav.len() as u64);
|
||||
assert!(MediaPool::default().open_file(&p, &crate::FsServices).unwrap().info().has_audio());
|
||||
let missing = dir.join("missing.wav").to_string_lossy().to_string();
|
||||
assert!(matches!(MediaPool::default().open_file(&missing, &crate::FsServices), Err(MediaError::Offline(_))));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ fn synchronize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// reference unit
|
||||
let reference = if let Some(c) = u64_p(p, "reference").map(ClipId) {
|
||||
units.iter().position(|u| u.members.contains(&c)).ok_or_else(|| bad(cmd, "`reference` is not among the clips"))?
|
||||
} else if let Some(t) = track_p(s, p, "track") {
|
||||
} else if let Some(t) = track_p(s, p, "track", cmd)? {
|
||||
units
|
||||
.iter()
|
||||
.position(|u| u.members.iter().any(|m| q.track(t).is_some_and(|tr| tr.item(*m).is_some())))
|
||||
|
||||
@@ -21,6 +21,15 @@ pub fn decode_json() -> Value {
|
||||
"draftFrames": g.draft,
|
||||
"h264Threads": filmcraft_codecs::video::h264_threads(),
|
||||
"evicted": g.evicted,
|
||||
// sources that hold a decoder right now (idle ones give theirs up beyond a cap)
|
||||
"liveDecoders": filmcraft_codecs::live_decoders(),
|
||||
// decoded frames all sources hold, and the budget idle sources are trimmed to
|
||||
"cacheMB": filmcraft_codecs::cached_bytes() as f64 / 1e6,
|
||||
"cacheBudgetMB": filmcraft_codecs::FRAME_BUDGET as f64 / 1e6,
|
||||
// plane buffers of evicted frames waiting for the next decoded pictures, and how many
|
||||
// planes were decoded into a recycled buffer
|
||||
"planePoolMB": filmcraft_frame::pool::stats().idle_bytes as f64 / 1e6,
|
||||
"planesReused": filmcraft_frame::pool::stats().reused,
|
||||
"decodeMs": g.decode_ns as f64 / 1e6,
|
||||
"decodeMsPerSample": g.decode_ms_per_sample(),
|
||||
"framesDecoded": g.frames,
|
||||
@@ -71,6 +80,11 @@ mod tests {
|
||||
"decodeMs",
|
||||
"decodeMsPerSample",
|
||||
"framesDecoded",
|
||||
"liveDecoders",
|
||||
"cacheMB",
|
||||
"cacheBudgetMB",
|
||||
"planePoolMB",
|
||||
"planesReused",
|
||||
] {
|
||||
assert!(v["decode"][k].is_number(), "decode.{k} in {v}");
|
||||
}
|
||||
|
||||
@@ -342,6 +342,12 @@ fn apply(s: &mut Session, plans: Vec<Plan>, align_timecode: bool, label: &str) -
|
||||
m.info = info.clone();
|
||||
}
|
||||
proj.shift_media_time(p.item, shift);
|
||||
if p.cand.info.is_some() {
|
||||
// The item's size is now the file's own. Clips that came from an interchange import
|
||||
// while the file was missing still have an "auto" (NaN) anchor waiting for it:
|
||||
// centre it in the real picture. Points that are numbers are not touched.
|
||||
proj.resolve_placed_auto_points(|_| true, |source| source.id == p.item);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! `edit.rippleDelete` on a clip whose sound edge was split (a J or an L cut): the clip and its
|
||||
//! sound go, and the later clips move up by one amount on every track.
|
||||
|
||||
use filmcraft_project::ClipId;
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
|
||||
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
|
||||
}
|
||||
|
||||
/// Four 48-frame clips in a row, pictures on V1 and their linked sounds on A1: [(picture, sound); 4].
|
||||
fn four_clips(s: &mut Session) -> [(ClipId, ClipId); 4] {
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "Split edits", "video": 1, "audio": 1})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
for n in 0..4 {
|
||||
s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
|
||||
}
|
||||
let q = s.active_sequence().unwrap();
|
||||
[0, 1, 2, 3].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
|
||||
}
|
||||
|
||||
/// Move one sound edge alone, as a split edit does (Linked Selection off for the trim).
|
||||
fn trim_sound(s: &mut Session, sound: ClipId, edge: &str, frames: i64) {
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": sound.0, "edge": edge, "mode": "regular", "deltaFrames": frames})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
}
|
||||
|
||||
/// The second clip's sound starts 12 frames early (a J cut). Ripple delete was refused: the picture
|
||||
/// and the sound have different ranges, each range was closed on its own, and the clips after the
|
||||
/// next one were moved up twice, onto it. With a single clip after it the delete went through and
|
||||
/// a later marker moved twice.
|
||||
#[test]
|
||||
fn ripple_delete_of_a_j_cut_clip_closes_one_gap() {
|
||||
let mut s = Session::default();
|
||||
let [(_, sound1), (picture2, sound2), (picture3, sound3), (picture4, sound4)] = four_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f36, f48, f72, f96, f120, f144) = (f(36), f(48), f(72), f(96), f(120), f(144));
|
||||
trim_sound(&mut s, sound1, "out", -12);
|
||||
trim_sound(&mut s, sound2, "in", -12);
|
||||
assert_eq!((range(&s, picture2).0, range(&s, sound2).0), (f48, f36), "the sound leads by 12 frames");
|
||||
s.state.ripple_sequence_markers = true;
|
||||
let marker = s.execute("markers.add", json!({"time": f120.0, "name": "Later"})).unwrap()["marker"].clone();
|
||||
let before = s.project.clone();
|
||||
|
||||
s.execute("edit.rippleDelete", json!({"clips": [picture2.0]})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
assert!(q.find_item(picture2).is_none() && q.find_item(sound2).is_none(), "the clip and its sound are gone");
|
||||
assert_eq!(range(&s, picture3), (f48, f96), "the next picture closes the gap");
|
||||
assert_eq!(range(&s, sound3), (f48, f96), "and its sound moves up by the same amount");
|
||||
assert_eq!((range(&s, picture4), range(&s, sound4)), ((f96, f144), (f96, f144)), "the clip after it moves up once");
|
||||
assert_eq!(range(&s, sound1).1, f36, "the sound before is untouched");
|
||||
assert_eq!(q.duration(), f144);
|
||||
let at = q.markers.iter().find(|m| json!(m.id.0) == marker).map(|m| m.start);
|
||||
assert_eq!(at, Some(f72), "a later marker moves up once, by the same amount");
|
||||
let after = s.project.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before, "one undo step restores it");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *after);
|
||||
}
|
||||
|
||||
/// The second clip's sound runs 12 frames under the next picture (an L cut).
|
||||
#[test]
|
||||
fn ripple_delete_of_an_l_cut_clip_keeps_the_next_clip_in_sync() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, sound2), (picture3, sound3), (picture4, sound4)] = four_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f48, f60, f96, f108, f144) = (f(48), f(60), f(96), f(108), f(144));
|
||||
trim_sound(&mut s, sound3, "in", 12);
|
||||
trim_sound(&mut s, sound2, "out", 12);
|
||||
assert_eq!((range(&s, picture2).1, range(&s, sound2).1), (f96, f108), "the sound runs on by 12 frames");
|
||||
|
||||
s.execute("edit.rippleDelete", json!({"clips": [picture2.0]})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
assert!(q.find_item(sound2).is_none());
|
||||
assert_eq!(range(&s, picture3), (f48, f96));
|
||||
assert_eq!(range(&s, sound3), (f60, f96), "the next sound still starts 12 frames after its picture");
|
||||
assert_eq!((range(&s, picture4), range(&s, sound4)), ((f96, f144), (f96, f144)));
|
||||
}
|
||||
|
||||
/// A straight cut is unchanged: the picture and its sound share one range and close one gap.
|
||||
#[test]
|
||||
fn ripple_delete_of_a_straight_cut_clip_is_unchanged() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, _), (picture3, sound3), _] = four_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f48, f96) = (f(48), f(96));
|
||||
s.execute("edit.rippleDelete", json!({"clips": [picture2.0]})).unwrap();
|
||||
assert_eq!((range(&s, picture3), range(&s, sound3)), ((f48, f96), (f48, f96)));
|
||||
}
|
||||
|
||||
/// Two neighbours deleted together, with a split edit between them (the first one's sound runs 12
|
||||
/// frames under the second picture): one gap closes, the size of both clips.
|
||||
#[test]
|
||||
fn ripple_delete_of_two_neighbours_with_a_split_edit_between_them() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, sound2), (picture3, sound3), (picture4, sound4)] = four_clips(&mut s);
|
||||
let f = |n: i64| s.sequence_rate().tick_of(n);
|
||||
let (f48, f96) = (f(48), f(96));
|
||||
trim_sound(&mut s, sound3, "in", 12);
|
||||
trim_sound(&mut s, sound2, "out", 12);
|
||||
s.execute("edit.rippleDelete", json!({"clips": [picture2.0, picture3.0]})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
assert!([picture2, sound2, picture3, sound3].iter().all(|c| q.find_item(*c).is_none()));
|
||||
assert_eq!((range(&s, picture4), range(&s, sound4)), ((f48, f96), (f48, f96)), "the clip after them closes the whole gap");
|
||||
}
|
||||
|
||||
/// Straight cuts with the sounds on alternating audio tracks: deleting two neighbours closes the
|
||||
/// whole gap, as it did before.
|
||||
#[test]
|
||||
fn ripple_delete_of_two_neighbours_with_sounds_on_alternating_tracks() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "Alternating", "video": 1, "audio": 2})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
let mut pictures = Vec::new();
|
||||
for n in 0..4 {
|
||||
let track = if n % 2 == 0 { "A1" } else { "A2" };
|
||||
let r = s.execute("timeline.place", json!({"item": item.0, "audioTrack": track, "frame": 48 * n, "duration": rate.tick_of(48).0})).unwrap();
|
||||
pictures.push(ClipId(r["clips"][0].as_u64().unwrap()));
|
||||
}
|
||||
s.execute("edit.rippleDelete", json!({"clips": [pictures[1].0, pictures[2].0]})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
assert_eq!(range(&s, pictures[3]), (rate.tick_of(48), rate.tick_of(96)));
|
||||
assert_eq!(q.audio_tracks[1].items.iter().map(|i| i.range()).collect::<Vec<_>>(), vec![q.video_tracks[0].items[1].range()], "its sound moved with it");
|
||||
assert_eq!(q.duration(), rate.tick_of(96));
|
||||
}
|
||||
@@ -225,7 +225,7 @@ fn transcribe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let track = match p.get("track") {
|
||||
None => None,
|
||||
Some(Value::String(t)) if t.eq_ignore_ascii_case("mix") => None,
|
||||
Some(_) => Some(crate::commands::track_p(s, p, "track").ok_or_else(|| bad("sequence.transcribe", "unknown `track`"))?),
|
||||
Some(_) => Some(crate::commands::track_p(s, p, "track", "sequence.transcribe")?.ok_or_else(|| bad("sequence.transcribe", "unknown `track`"))?),
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
for t in q.audio_tracks.iter().filter(|t| track.is_none_or(|x| x == t.id)) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! `sequence.inspect` is how an agent checks its edits: it has to show where a clip ends in the
|
||||
//! sequence and in its media, the clip's audio gain, whether it plays backward and what a marker says.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn sequence_inspect_reports_clip_ends_gain_and_marker_comments() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
// a clip at 200 %, a clip with gain and a marker with a comment, all made through commands
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
let (fast, loud) = {
|
||||
let q = s.active_sequence().unwrap();
|
||||
(q.video_tracks[0].items.last().unwrap().id, q.audio_tracks[0].items[0].id)
|
||||
};
|
||||
s.execute("timeline.select", json!({"clips": [fast.0]})).unwrap();
|
||||
s.execute("clip.speedDuration", json!({"speed": 200})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [loud.0]})).unwrap();
|
||||
s.execute("clip.audioGain", json!({"mode": "set", "db": -6.5})).unwrap();
|
||||
let marker = s.execute("markers.add", json!({"time": rate.tick_of(12).0, "name": "Beat", "comment": "cut on the downbeat"})).unwrap()["marker"].clone();
|
||||
|
||||
let q = s.active_sequence().unwrap().clone();
|
||||
let js = s.execute("sequence.inspect", json!({})).unwrap();
|
||||
let m = js["markers"].as_array().unwrap().iter().find(|m| m["id"] == marker).unwrap();
|
||||
assert_eq!((m["name"].as_str(), m["comment"].as_str()), (Some("Beat"), Some("cut on the downbeat")));
|
||||
let mut seen = 0;
|
||||
for (key, tracks) in [("video", &q.video_tracks), ("audio", &q.audio_tracks)] {
|
||||
for (ti, t) in tracks.iter().enumerate() {
|
||||
for (ci, c) in t.items.iter().enumerate() {
|
||||
let j = &js[key][ti]["items"][ci];
|
||||
assert_eq!(j["clip"], c.id.0);
|
||||
assert_eq!(j["end"], c.end().0, "{j}");
|
||||
assert_eq!(j["end"].as_i64(), j["start"].as_i64().zip(j["duration"].as_i64()).map(|(a, b)| a + b));
|
||||
assert_eq!(j["endFrame"], rate.frame_at(c.end()));
|
||||
assert_eq!(j["sourceOut"], c.source_out().0, "{j}");
|
||||
assert_eq!(j["gainDb"], c.gain_db);
|
||||
// the keys that were there before stay
|
||||
for old in ["item", "name", "start", "duration", "startFrame", "durationFrames", "sourceIn", "speed", "enabled", "link", "label", "effects"] {
|
||||
assert!(j.get(old).is_some(), "{old} is still reported");
|
||||
}
|
||||
if c.id == fast {
|
||||
assert_eq!(j["speed"], 2.0);
|
||||
assert_eq!(
|
||||
j["sourceOut"].as_i64().unwrap() - j["sourceIn"].as_i64().unwrap(),
|
||||
2 * j["duration"].as_i64().unwrap(),
|
||||
"at 200% a clip uses twice its length of media"
|
||||
);
|
||||
seen += 1;
|
||||
}
|
||||
if c.id == loud {
|
||||
assert_eq!(j["gainDb"], -6.5);
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(seen, 2, "both edited clips were found in the readback");
|
||||
}
|
||||
|
||||
/// A clip set to play backward read back exactly like a forward one: same `speed`, same source
|
||||
/// range, and nothing that said it was reversed.
|
||||
#[test]
|
||||
fn sequence_inspect_reports_a_reversed_clip() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let backward = s.active_sequence().unwrap().video_tracks[0].items[1].id;
|
||||
let all = |js: &serde_json::Value| -> Vec<(u64, bool)> {
|
||||
["video", "audio"]
|
||||
.iter()
|
||||
.flat_map(|k| js[*k].as_array().unwrap().iter())
|
||||
.flat_map(|t| t["items"].as_array().unwrap().iter())
|
||||
.map(|i| (i["clip"].as_u64().unwrap(), i["reverse"].as_bool().expect("every clip reports `reverse`")))
|
||||
.collect()
|
||||
};
|
||||
let js = s.execute("sequence.inspect", json!({})).unwrap();
|
||||
assert!(all(&js).iter().all(|(_, reversed)| !reversed), "nothing plays backward yet");
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [backward.0]})).unwrap();
|
||||
s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true})).unwrap();
|
||||
let js = s.execute("sequence.inspect", json!({})).unwrap();
|
||||
let reversed: Vec<u64> = all(&js).into_iter().filter(|(_, r)| *r).map(|(c, _)| c).collect();
|
||||
assert_eq!(reversed, vec![backward.0], "only the reversed clip says so");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let js = s.execute("sequence.inspect", json!({})).unwrap();
|
||||
assert!(all(&js).iter().all(|(_, reversed)| !reversed));
|
||||
}
|
||||
@@ -304,7 +304,7 @@ fn go_to_gap(s: &mut Session, p: &Value, next: bool, in_track: bool) -> Result<V
|
||||
let t = s.playhead();
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let gaps: Vec<TimeRange> = if in_track {
|
||||
let tracks: Vec<TrackId> = match track_p(s, p, "track") {
|
||||
let tracks: Vec<TrackId> = match track_p(s, p, "track", if next { "sequence.goToNextGapInTrack" } else { "sequence.goToPrevGapInTrack" })? {
|
||||
Some(id) => vec![id],
|
||||
None => s.targeting().targeted,
|
||||
};
|
||||
@@ -460,7 +460,7 @@ fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
tracks.iter().filter(|t| t.items.is_empty()).map(|t| t.id).collect()
|
||||
}
|
||||
Some(_) => {
|
||||
let id = track_p(s, p, key).ok_or_else(|| bad("sequence.deleteTracks", format!("unknown {key} track")))?;
|
||||
let id = track_p(s, p, key, "sequence.deleteTracks")?.ok_or_else(|| bad("sequence.deleteTracks", format!("unknown {key} track")))?;
|
||||
if !tracks.iter().any(|t| t.id == id) {
|
||||
return Err(bad("sequence.deleteTracks", format!("`{key}` must name a {key} track")));
|
||||
}
|
||||
|
||||
@@ -205,6 +205,11 @@ fn list_searches_labels_and_keys() {
|
||||
let r = s.shortcuts.commands();
|
||||
assert!(r.iter().any(|c| c.id == "trim.forward"));
|
||||
assert!(!r.iter().any(|c| c.id == "timeline.move"), "commands with required params are not bindable");
|
||||
// a parameter doc holds parameters only (each optional or with a default): a result
|
||||
// description in it would take the command out of this list
|
||||
for id in ["clip.replaceFromBin", "clip.replaceFromSource", "clip.replaceFromSourceMatchFrame"] {
|
||||
assert!(r.iter().any(|c| c.id == id), "{id} has no required parameter, so it can be bound");
|
||||
}
|
||||
let mut s = s;
|
||||
let v = s.execute("shortcuts.list", json!({"query": "ripple delete"})).unwrap();
|
||||
assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "edit.rippleDelete"), "{v}");
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A ripple trim ahead of a split edit: the later clip's sound starts before its picture (a J cut)
|
||||
//! and has to move with it.
|
||||
|
||||
use filmcraft_project::ClipId;
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn start(s: &Session, c: ClipId) -> Tick {
|
||||
s.active_sequence().unwrap().find_item(c).map(|(_, i)| i.start).unwrap()
|
||||
}
|
||||
|
||||
/// Two clips in a row on V1. The first has its sound on A1, the second on A2, and the second's
|
||||
/// sound starts 12 frames before its picture. Returns (first picture, second picture, second sound).
|
||||
fn j_cut(s: &mut Session) -> (ClipId, ClipId, ClipId) {
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "J cut", "video": 1, "audio": 2})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
let (source_in, len) = (rate.tick_of(48).0, rate.tick_of(48).0);
|
||||
s.execute("timeline.place", json!({"item": item.0, "track": "V1", "audioTrack": "A1", "frame": 0, "sourceIn": source_in, "duration": len})).unwrap();
|
||||
s.execute("timeline.place", json!({"item": item.0, "track": "V1", "audioTrack": "A2", "frame": 48, "sourceIn": source_in, "duration": len})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (first, second, sound) = (q.video_tracks[0].items[0].id, q.video_tracks[0].items[1].id, q.audio_tracks[1].items[0].id);
|
||||
// the split: only the sound's In edge moves, so Linked Selection is off for this one trim
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": sound.0, "edge": "in", "mode": "regular", "deltaFrames": -12})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
assert_eq!(start(s, second) - start(s, sound), rate.tick_of(12), "the sound leads its picture by 12 frames");
|
||||
(first, second, sound)
|
||||
}
|
||||
|
||||
/// Lengthening the clip before a J cut moved the later picture and left its sound behind: a lead
|
||||
/// of 12 frames became 20, with no error.
|
||||
#[test]
|
||||
fn lengthening_before_a_j_cut_keeps_the_sound_with_its_picture() {
|
||||
ripple_and_check(8);
|
||||
}
|
||||
|
||||
/// Shortening it was refused as a sync-lock conflict, because the early sound sat in the stretch
|
||||
/// that closes.
|
||||
#[test]
|
||||
fn shortening_before_a_j_cut_keeps_the_sound_with_its_picture() {
|
||||
ripple_and_check(-8);
|
||||
}
|
||||
|
||||
fn ripple_and_check(frames: i64) {
|
||||
let mut s = Session::default();
|
||||
let (first, second, sound) = j_cut(&mut s);
|
||||
let rate = s.sequence_rate();
|
||||
let (picture_at, sound_at) = (start(&s, second), start(&s, sound));
|
||||
let before = s.project.clone();
|
||||
let r = s.execute("timeline.trim", json!({"clip": first.0, "edge": "out", "mode": "ripple", "deltaFrames": frames})).unwrap();
|
||||
let by = rate.tick_of(frames);
|
||||
assert_eq!(r["delta"], json!(by.0));
|
||||
assert_eq!(start(&s, second), picture_at + by, "the later picture ripples");
|
||||
assert_eq!(start(&s, sound), sound_at + by, "its sound still leads by 12 frames");
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
let after = s.project.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before, "one undo step puts both back");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *after);
|
||||
}
|
||||
|
||||
/// The early sound has no room to follow: the sequence start, or another clip right before it on
|
||||
/// its track. The trim is refused and nothing moves.
|
||||
#[test]
|
||||
fn ripple_trim_is_refused_when_the_early_sound_has_no_room() {
|
||||
let mut s = Session::default();
|
||||
let (first, second, sound) = j_cut(&mut s);
|
||||
let rate = s.sequence_rate();
|
||||
let before = s.project.clone();
|
||||
// the sound starts at frame 36: 40 frames earlier is before the sequence starts
|
||||
let e = s.execute("timeline.trim", json!({"clip": first.0, "edge": "out", "mode": "ripple", "deltaFrames": -40})).unwrap_err();
|
||||
assert!(e.to_string().contains("sync"), "{e}");
|
||||
assert_eq!(*s.project, *before, "a refused trim changes nothing");
|
||||
let tone = s.project.items.values().find(|i| !i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
s.execute("timeline.place", json!({"item": tone.0, "audioTrack": "A2", "frame": 12, "duration": rate.tick_of(24).0})).unwrap();
|
||||
let before = s.project.clone();
|
||||
assert_eq!(s.active_sequence().unwrap().audio_tracks[1].items[0].end(), start(&s, sound));
|
||||
let e = s.execute("timeline.trim", json!({"clip": first.0, "edge": "out", "mode": "ripple", "deltaFrames": -8})).unwrap_err();
|
||||
assert!(e.to_string().contains("sync"), "{e}");
|
||||
assert_eq!(*s.project, *before, "a refused trim changes nothing");
|
||||
assert_eq!(start(&s, second) - start(&s, sound), rate.tick_of(12));
|
||||
}
|
||||
@@ -335,6 +335,39 @@ fn interchange_roundtrip_through_files() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `file.exportInterchange {format: "<unknown>"}` used to write FCP7 XML without a word.
|
||||
#[test]
|
||||
fn export_interchange_refuses_an_unknown_format() {
|
||||
let d = std::env::temp_dir().join(format!("fc-ix-format-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let path = |n: &str| d.join(n).to_string_lossy().to_string();
|
||||
let mut s = demo();
|
||||
// unknown names, values that are not names, and spellings that are not the documented ones
|
||||
for bad in [json!("premiere"), json!("mp4"), json!(""), json!(7), json!(["xml"]), json!(".xml"), json!("fcpxmld"), json!("omfi"), json!("xml ")] {
|
||||
let e = s.execute("file.exportInterchange", json!({"format": bad, "path": path("bad.out")})).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "file.exportInterchange"), "{bad}: {e:?}");
|
||||
let e = e.to_string();
|
||||
assert!(e.contains("unknown format") && e.ends_with("use one of edl, xml, fcpxml, otio, aaf, omf"), "the error lists exactly the accepted names: {e}");
|
||||
assert!(!d.join("bad.out").exists(), "nothing is written for {bad}");
|
||||
}
|
||||
// the documented formats still export (in either case), and no format still means FCP7 XML
|
||||
let text = |n: &str| String::from_utf8_lossy(&std::fs::read(d.join(n)).unwrap()).into_owned();
|
||||
s.execute("file.exportInterchange", json!({"path": path("default.xml")})).unwrap();
|
||||
s.execute("file.exportInterchange", json!({"format": "xml", "path": path("a.xml")})).unwrap();
|
||||
assert!(text("default.xml").contains("<xmeml") && text("a.xml").contains("<xmeml"));
|
||||
s.execute("file.exportInterchange", json!({"format": "fcpxml", "path": path("a.fcpxml")})).unwrap();
|
||||
assert!(text("a.fcpxml").contains("<fcpxml"));
|
||||
s.execute("file.exportInterchange", json!({"format": "otio", "path": path("a.otio")})).unwrap();
|
||||
assert!(text("a.otio").contains("OTIO_SCHEMA"));
|
||||
s.execute("file.exportInterchange", json!({"format": "EDL", "path": path("a.edl")})).unwrap();
|
||||
assert!(text("a.edl").contains("TITLE:"));
|
||||
for (format, magic) in [("aaf", "aaf"), ("omf", "omf")] {
|
||||
let r = s.execute("file.exportInterchange", json!({"format": format, "path": path(&format!("a.{magic}"))})).unwrap();
|
||||
assert!(r["bytes"].as_u64().unwrap() > 0, "{format}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_items_to_bin_and_back_with_undo() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! `timeline.move`: linked partners follow, locked partners stay, the group stops together at the
|
||||
//! sequence start, and hostile times are refused.
|
||||
|
||||
use filmcraft_project::{ClipId, TrackId, TrackItem};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn state(s: &Session, c: ClipId) -> (TrackId, Tick) {
|
||||
s.active_sequence().unwrap().find_item(c).map(|(t, i)| (t, i.start)).unwrap()
|
||||
}
|
||||
|
||||
/// The last picture clip of V1 and its linked sound: (V1, picture, sound's track, sound).
|
||||
fn last_pair(s: &Session) -> (TrackId, TrackItem, TrackId, TrackItem) {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let v = q.video_tracks[0].items.last().unwrap().clone();
|
||||
let (a_track, a) = q.audio_tracks.iter().find_map(|t| t.items.iter().find(|i| i.link == v.link && v.link.is_some()).map(|i| (t.id, i.clone()))).unwrap();
|
||||
(q.video_tracks[0].id, v, a_track, a)
|
||||
}
|
||||
|
||||
/// An empty sequence with one linked picture + sound pair placed at `seconds`: (V1, picture, A1, sound).
|
||||
fn pair_in_an_empty_sequence(s: &mut Session, seconds: f64) -> (TrackId, TrackItem, TrackId, TrackItem) {
|
||||
s.execute("file.newSequence", json!({"name": "Moves", "video": 2, "audio": 2})).unwrap();
|
||||
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
s.execute("timeline.place", json!({"item": item.0, "seconds": seconds})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (v, a) = (q.video_tracks[0].items[0].clone(), q.audio_tracks[0].items[0].clone());
|
||||
assert!(v.link.is_some() && v.link == a.link, "a placed clip's picture and sound are linked");
|
||||
(q.video_tracks[0].id, v, q.audio_tracks[0].id, a)
|
||||
}
|
||||
|
||||
/// `timeline.move` of one clip left its linked sound behind although Linked Selection was on.
|
||||
#[test]
|
||||
fn move_takes_linked_partners_along() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let (v_track, v, a_track, a) = last_pair(&s);
|
||||
let end = s.active_sequence().unwrap().duration();
|
||||
assert!(s.state.linked_selection, "linked selection is on by default");
|
||||
let before = s.project.clone();
|
||||
// only the picture is listed: the sound follows by the same offset, on its own track
|
||||
let to = end + rate.tick_of(48);
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": to.0}]})).unwrap();
|
||||
assert_eq!(state(&s, v.id), (v_track, to));
|
||||
assert_eq!(state(&s, a.id), (a_track, a.start + (to - v.start)), "linked audio stays in sync");
|
||||
assert_eq!(r["moved"], json!([v.id.0, a.id.0]));
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before, "one undo step restores both clips");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!((state(&s, v.id).1, state(&s, a.id).1), (to, a.start + (to - v.start)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// listing both moves each exactly once
|
||||
let (tv, ta) = (to, a.start + (to - v.start));
|
||||
let r = s
|
||||
.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": tv.0}, {"clip": a.id.0, "track": a_track.0, "time": ta.0}]}))
|
||||
.unwrap();
|
||||
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, tv), (a_track, ta)));
|
||||
assert_eq!(r["moved"], json!([v.id.0, a.id.0]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// `linked: false` moves just the listed clip, whatever the toggle says
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": to.0}], "linked": false})).unwrap();
|
||||
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, to), (a_track, a.start)));
|
||||
assert_eq!(r["moved"], json!([v.id.0]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// linked selection off: unchanged behaviour, only the listed clip moves
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": to.0}]})).unwrap();
|
||||
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, to), (a_track, a.start)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
// ...unless the command asks for it
|
||||
s.execute("timeline.move", json!({"moves": [{"clip": a.id.0, "track": a_track.0, "time": ta.0}], "linked": true})).unwrap();
|
||||
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, tv), (a_track, ta)), "moving the sound takes the picture along");
|
||||
}
|
||||
|
||||
/// A partner on a locked track cannot move; that must not refuse the move of the listed clip.
|
||||
#[test]
|
||||
fn a_linked_partner_on_a_locked_track_stays_and_the_listed_clip_still_moves() {
|
||||
let mut s = demo();
|
||||
let (v_track, v, a_track, a) = last_pair(&s);
|
||||
let to = s.active_sequence().unwrap().duration() + s.sequence_rate().tick_of(48);
|
||||
s.execute("timeline.setTrack", json!({"track": a_track.0, "locked": true})).unwrap();
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": to.0}]})).unwrap();
|
||||
assert_eq!(r["moved"], json!([v.id.0]), "the locked sound is not among the moved clips");
|
||||
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, to), (a_track, a.start)));
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(state(&s, v.id), (v_track, v.start));
|
||||
// a listed clip whose own destination is locked is still refused, and nothing moves
|
||||
let before = s.project.clone();
|
||||
assert!(s.execute("timeline.move", json!({"moves": [{"clip": a.id.0, "track": a_track.0, "time": to.0}]})).is_err());
|
||||
assert_eq!(*s.project, *before);
|
||||
}
|
||||
|
||||
/// A partner that would land where it already is does not move: re-placing it would drop the
|
||||
/// transitions at its edges and list it in `moved` for nothing. Moving only the picture to
|
||||
/// another track at the same time leaves the sound as it was.
|
||||
#[test]
|
||||
fn a_partner_whose_position_does_not_change_is_left_alone() {
|
||||
let mut s = demo();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let a = q.audio_tracks[0].items[1].clone();
|
||||
let (v_track, v) = q.video_tracks.iter().find_map(|t| t.items.iter().find(|i| i.link == a.link && a.link.is_some()).map(|i| (t.id, i.clone()))).unwrap();
|
||||
let (a_track, v2) = (q.audio_tracks[0].id, q.video_tracks[1].id);
|
||||
assert_ne!(v_track, v2);
|
||||
s.execute("sequence.applyAudioTransition", json!({"clip": a.id.0})).unwrap();
|
||||
let sound = |s: &Session| s.active_sequence().unwrap().track(a_track).unwrap().clone();
|
||||
let before = sound(&s);
|
||||
assert_eq!(before.transitions.len(), 1, "the sound clip has a crossfade");
|
||||
// picture to V2 at the same time, Linked Selection on
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v2.0, "time": v.start.0}]})).unwrap();
|
||||
assert_eq!(state(&s, v.id), (v2, v.start));
|
||||
assert_eq!(sound(&s), before, "the sound track, its clip and its crossfade are untouched");
|
||||
assert_eq!(r["moved"], json!([v.id.0]), "the sound did not move, so it is not listed");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!((state(&s, v.id), sound(&s)), ((v_track, v.start), before.clone()));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!((state(&s, v.id), sound(&s)), ((v2, v.start), before.clone()));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// a real offset still takes the sound along
|
||||
let later = v.start + s.sequence_rate().tick_of(2);
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v2.0, "time": later.0}]})).unwrap();
|
||||
assert_eq!(r["moved"], json!([v.id.0, a.id.0]));
|
||||
assert_eq!(state(&s, a.id).1 - a.start, later - v.start);
|
||||
}
|
||||
|
||||
/// When a clip would start before the sequence start, every clip of the call lands later by the
|
||||
/// same amount: a split edit keeps its offset and several listed clips keep their spacing.
|
||||
#[test]
|
||||
fn the_group_stops_together_at_the_sequence_start() {
|
||||
let mut s = demo();
|
||||
let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0);
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
let (lead, gap) = (f(10), f(100));
|
||||
// make a split edit: the sound starts 10 frames before the picture
|
||||
s.execute("timeline.move", json!({"moves": [{"clip": a.id.0, "track": a_track.0, "time": (a.start - lead).0}], "linked": false})).unwrap();
|
||||
assert_eq!(state(&s, a.id).1, v.start - lead);
|
||||
// ask for the picture at 4 frames: the sound would start 6 frames before zero
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v_track.0, "time": f(4).0}]})).unwrap();
|
||||
assert_eq!(r["moved"], json!([v.id.0, a.id.0]));
|
||||
assert_eq!((state(&s, v.id).1, state(&s, a.id).1), (lead, Tick::ZERO), "the sound stops at zero and the picture keeps its 10 frames");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!((state(&s, v.id).1, state(&s, a.id).1), (v.start, v.start - lead));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!((state(&s, v.id).1, state(&s, a.id).1), (lead, Tick::ZERO));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// a second, unlinked clip 100 frames after the picture, on V2; both listed, both asked 20 frames too far left
|
||||
let item = s.active_sequence().unwrap().video_tracks[0].items[0].item;
|
||||
let v2 = s.active_sequence().unwrap().video_tracks[1].id;
|
||||
s.execute("timeline.place", json!({"item": item.0, "time": (v.start + gap).0, "track": v2.0, "audioTrack": "A2"})).unwrap();
|
||||
let w = s.active_sequence().unwrap().video_tracks[1].items[0].clone();
|
||||
let back = v.start + f(20);
|
||||
let moves = json!([{"clip": v.id.0, "track": v_track.0, "time": (v.start - back).0}, {"clip": w.id.0, "track": v2.0, "time": (w.start - back).0}]);
|
||||
s.execute("timeline.move", json!({"moves": moves, "linked": false})).unwrap();
|
||||
assert_eq!((state(&s, v.id).1, state(&s, w.id).1), (Tick::ZERO, gap), "the two clips are still 100 frames apart");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// with the links followed, the earliest clip of all (the split sound) is the one that stops at zero
|
||||
s.execute("timeline.move", json!({"moves": moves})).unwrap();
|
||||
assert_eq!((state(&s, a.id).1, state(&s, v.id).1, state(&s, w.id).1), (Tick::ZERO, lead, lead + gap));
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
}
|
||||
|
||||
/// AGENTS.md §0.8: `time` is any i64. Out-of-range values are parameter errors, never a panic
|
||||
/// (which `execute` would report as an internal error) and never a wrapped position.
|
||||
#[test]
|
||||
fn hostile_times_are_refused_or_clamped_never_a_panic() {
|
||||
let mut s = demo();
|
||||
let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0);
|
||||
let before = s.project.clone();
|
||||
for linked in [true, false] {
|
||||
for time in [i64::MAX, i64::MAX - 1, Tick::MAX.0 + 1] {
|
||||
let e = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v_track.0, "time": time}], "linked": linked})).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "timeline.move"), "{time} linked={linked}: {e:?}");
|
||||
assert_eq!(*s.project, *before, "{time}: nothing moved");
|
||||
}
|
||||
// far before the start: the clip lands at zero, as a small negative time does
|
||||
for time in [i64::MIN, i64::MIN + 1, -1] {
|
||||
s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v_track.0, "time": time}], "linked": linked})).unwrap();
|
||||
assert_eq!(state(&s, v.id).1, Tick::ZERO, "{time}");
|
||||
assert_eq!(state(&s, a.id).1, if linked { Tick::ZERO } else { a.start }, "{time}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
// the sound asked far to the left and the picture far to the right in one call: out of range together
|
||||
let moves = json!([{"clip": v.id.0, "track": v_track.0, "time": Tick::MAX.0}, {"clip": a.id.0, "track": a_track.0, "time": i64::MIN}]);
|
||||
assert!(matches!(s.execute("timeline.move", json!({"moves": moves})), Err(EngineError::BadParams { .. })));
|
||||
// junk entries are skipped as before; a call with nothing usable is a parameter error
|
||||
for moves in
|
||||
[json!([]), json!([{"clip": v.id.0, "track": v_track.0, "time": "soon"}]), json!([{"clip": v.id.0, "track": v_track.0, "time": 1e300}]), json!("all")]
|
||||
{
|
||||
assert!(matches!(s.execute("timeline.move", json!({"moves": moves})), Err(EngineError::BadParams { .. })), "{moves}");
|
||||
}
|
||||
assert!(s.execute("timeline.move", json!({"moves": [{"clip": 987_654_321u64, "track": v_track.0, "time": 0}]})).is_err(), "a clip that does not exist");
|
||||
assert_eq!(*s.project, *before);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Track parameters (`track`, `audioTrack`: an id, or "V1" / "A2"): a track the active sequence
|
||||
//! does not have is a parameter error, never a silent default.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// `timeline.place {audioTrack: "A2"}` on a sequence with only A1 used to fall back to the
|
||||
/// source-patched track and overwrite what was on A1.
|
||||
#[test]
|
||||
fn naming_a_missing_track_is_an_error_not_a_default() {
|
||||
let mut s = demo();
|
||||
let seq = s.execute("file.newSequence", json!({"name": "One of each", "video": 1, "audio": 1})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (1, 1), "{seq}");
|
||||
let (v1, a1) = (q.video_tracks[0].id, q.audio_tracks[0].id);
|
||||
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
// omitted tracks keep their default: the source-patched V1 / A1
|
||||
s.execute("timeline.place", json!({"item": item.0, "time": 0})).unwrap();
|
||||
let before = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
for (key, name) in [("audioTrack", json!("A2")), ("track", json!("V2")), ("audioTrack", json!(999_999)), ("track", json!("V0")), ("track", json!("X1"))] {
|
||||
let mut p = json!({"item": item.0, "time": 0});
|
||||
p[key] = name.clone();
|
||||
let e = s.execute("timeline.place", p).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "timeline.place"), "a parameter error of the command: {e:?}");
|
||||
let e = e.to_string();
|
||||
let shown = name.as_str().map(str::to_string).unwrap_or_else(|| name.to_string());
|
||||
assert!(e.contains(&format!("no track {shown} in this sequence")), "{e}");
|
||||
}
|
||||
assert!(s.execute("timeline.place", json!({"item": item.0, "time": 0, "audioTrack": "A2"})).unwrap_err().to_string().contains("audioTrack"));
|
||||
// hostile names are errors too, never a panic
|
||||
for name in ["", "V", "é1", "1", "V-1", "A1.5", "V99999999999999999999"] {
|
||||
assert!(s.execute("timeline.place", json!({"item": item.0, "time": 0, "track": name})).is_err(), "{name:?}");
|
||||
}
|
||||
// every command that takes a track by name refuses one that is not there
|
||||
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id;
|
||||
let e = s.execute("timeline.move", json!({"moves": [{"clip": clip.0, "track": "V2", "time": 0}]})).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "timeline.move"), "{e:?}");
|
||||
assert!(s.execute("sequence.closeGap", json!({"track": "A2", "time": 0})).is_err());
|
||||
assert!(s.execute("sequence.deleteTrack", json!({"track": "V2"})).is_err());
|
||||
assert!(s.execute("sequence.deleteTrack", json!({"track": 999_999})).is_err());
|
||||
assert!(s.execute("timeline.setTrack", json!({"track": "A2", "muted": true})).is_err());
|
||||
assert!(s.execute("timeline.setTargeting", json!({"track": "V2", "targeted": true})).is_err());
|
||||
assert!(s.execute("timeline.razor", json!({"track": "A2", "time": 10})).is_err());
|
||||
assert!(s.execute("sequence.goToNextGapInTrack", json!({"track": "V2"})).is_err());
|
||||
assert_eq!(*s.project, *before, "nothing was edited");
|
||||
assert_eq!(s.history.undo.len(), undo, "and nothing was added to the undo history");
|
||||
// tracks that exist still resolve by name (either case) and by id
|
||||
s.execute("timeline.place", json!({"item": item.0, "seconds": 60.0, "track": "v1", "audioTrack": a1.0})).unwrap();
|
||||
s.execute("timeline.setTrack", json!({"track": v1.0, "locked": true})).unwrap();
|
||||
s.execute("timeline.setTrack", json!({"track": "a1", "muted": true})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert!(q.video_tracks[0].locked && q.audio_tracks[0].muted);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.active_sequence().unwrap().audio_tracks[0].muted);
|
||||
}
|
||||
|
||||
/// `timeline.place {track: "A3"}` put a picture item on the audio track A3 and a second sound
|
||||
/// clip on A1; a sound-only item went to A1 whatever audio track `track` named.
|
||||
#[test]
|
||||
fn place_on_an_audio_track_places_the_sound_there_and_nothing_else() {
|
||||
let mut s = demo();
|
||||
s.execute("file.newSequence", json!({"name": "Sound tracks", "video": 1, "audio": 3})).unwrap();
|
||||
let a3 = s.active_sequence().unwrap().audio_tracks[2].id;
|
||||
let with_picture = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
let sound_only = s.project.items.values().find(|i| !i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
let picture_only = s.project.items.values().find(|i| i.has_video() && !i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
// how many clips sit on [V1, A1, A2, A3]
|
||||
let counts = |s: &Session| {
|
||||
let q = s.active_sequence().unwrap();
|
||||
[q.video_tracks[0].items.len(), q.audio_tracks[0].items.len(), q.audio_tracks[1].items.len(), q.audio_tracks[2].items.len()]
|
||||
};
|
||||
// a clip with picture and sound, sent to A3 by name: its sound alone, on A3
|
||||
let r = s.execute("timeline.place", json!({"item": with_picture.0, "track": "A3", "frame": 0})).unwrap();
|
||||
assert_eq!(counts(&s), [0, 0, 0, 1], "{r}");
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(r["clips"], json!([q.audio_tracks[2].items[0].id.0]));
|
||||
assert_eq!(q.audio_tracks[2].items[0].link, None, "no picture was placed, so nothing to link to");
|
||||
q.check().unwrap();
|
||||
// a sound-only item goes to the audio track named, by name or by id
|
||||
s.execute("timeline.place", json!({"item": sound_only.0, "track": "A2", "frame": 0})).unwrap();
|
||||
assert_eq!(counts(&s), [0, 0, 1, 1]);
|
||||
s.execute("timeline.place", json!({"item": sound_only.0, "track": a3.0, "seconds": 120.0})).unwrap();
|
||||
assert_eq!(counts(&s), [0, 0, 1, 2]);
|
||||
// what cannot be meant is a parameter error that says why, and changes nothing
|
||||
let before = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
for (p, why) in [
|
||||
(json!({"item": with_picture.0, "track": "A3", "audioTrack": "A1", "seconds": 300.0}), "name the sound's track once"),
|
||||
(json!({"item": with_picture.0, "audioTrack": "V1", "seconds": 300.0}), "`audioTrack` names a video track"),
|
||||
(json!({"item": with_picture.0, "track": "V1", "audioTrack": "V1", "seconds": 300.0}), "`audioTrack` names a video track"),
|
||||
(json!({"item": picture_only.0, "track": "A1", "seconds": 300.0}), "this item has no sound"),
|
||||
(json!({"item": sound_only.0, "track": "V1", "seconds": 300.0}), "this item has no picture"),
|
||||
] {
|
||||
let e = s.execute("timeline.place", p.clone()).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "timeline.place"), "{p}: {e:?}");
|
||||
assert!(e.to_string().contains(why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(*s.project, *before, "a refused place changes nothing");
|
||||
assert_eq!(s.history.undo.len(), undo, "and adds nothing to the undo history");
|
||||
// a sound-only item dropped on a video row still lands on the audio track sent with it
|
||||
s.execute("timeline.place", json!({"item": sound_only.0, "track": "V1", "audioTrack": "A1", "seconds": 900.0})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// the usual forms are unchanged: picture on `track`, sound on `audioTrack` or the default A1
|
||||
s.execute("timeline.place", json!({"item": with_picture.0, "track": "V1", "audioTrack": "A2", "seconds": 300.0})).unwrap();
|
||||
assert_eq!(counts(&s), [1, 0, 2, 2]);
|
||||
s.execute("timeline.place", json!({"item": with_picture.0, "seconds": 600.0})).unwrap();
|
||||
assert_eq!(counts(&s), [2, 1, 2, 2]);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! An export depends only on the project and the settings: the same file on every machine, however
|
||||
//! many cores render it and however the work is cut into steps.
|
||||
|
||||
use super::*;
|
||||
use crate::tests::project;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("fc-export-det-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Export on a machine with `threads` cores: `export` renders one frame per core of the pool it
|
||||
/// runs in.
|
||||
fn export_with_cores(threads: usize, settings: &ExportSettings) -> Vec<u8> {
|
||||
let (p, seq, m) = project();
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
|
||||
pool.install(|| export(&p, seq, settings, &m, &Progress::default())).unwrap();
|
||||
std::fs::read(&settings.path).unwrap()
|
||||
}
|
||||
|
||||
/// Export a step at a time with `batch` frames per step (the web app uses 1).
|
||||
fn export_stepped(batch: i64, settings: &ExportSettings) -> Vec<u8> {
|
||||
let (p, seq, m) = project();
|
||||
let prog = Progress::default();
|
||||
let mut ex = Exporter::new(p, seq, settings, &prog).unwrap();
|
||||
ex.set_batch(batch);
|
||||
while !matches!(ex.step(&m, &prog).unwrap(), Step::Done(_)) {}
|
||||
std::fs::read(&settings.path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_file_whatever_the_core_count() {
|
||||
// 24 frames: one full interleave group and a partial one
|
||||
for (format, ext) in [(Format::ProRes, "mov"), (Format::H264, "mp4"), (Format::Mjpeg, "mov")] {
|
||||
let settings = |name: &str| ExportSettings { format, path: tmp(&format!("{name}.{ext}")), ..Default::default() };
|
||||
let reference = export_with_cores(2, &settings(&format!("{}-2", format.id())));
|
||||
for threads in [3, 5, 16] {
|
||||
let other = export_with_cores(threads, &settings(&format!("{}-{threads}", format.id())));
|
||||
assert!(reference == other, "{}: {threads} cores give a different file than 2 cores", format.label());
|
||||
}
|
||||
for batch in [1, 7] {
|
||||
let other = export_stepped(batch, &settings(&format!("{}-b{batch}", format.id())));
|
||||
assert!(reference == other, "{}: {batch} frame(s) per step give a different file", format.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slice NAL units (types 1 and 5) in a length-prefixed (4-byte) H.264 sample.
|
||||
fn slices_in(sample: &[u8]) -> usize {
|
||||
let (mut n, mut i) = (0, 0);
|
||||
while i + 4 <= sample.len() {
|
||||
let len = u32::from_be_bytes([sample[i], sample[i + 1], sample[i + 2], sample[i + 3]]) as usize;
|
||||
if let Some(&h) = sample.get(i + 4)
|
||||
&& matches!(h & 0x1f, 1 | 5)
|
||||
{
|
||||
n += 1;
|
||||
}
|
||||
i += 4 + len;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_slices_follow_the_frame_size_not_the_cores() {
|
||||
// 4096 lines = 256 macroblock rows: one slice per four rows on every machine, also on machines
|
||||
// with fewer than 64 cores (the encoder's default caps the slices at the core count)
|
||||
let path = tmp("tall.mp4");
|
||||
let s = ExportSettings { format: Format::H264, path: path.clone(), frame_size: Some((64, 4096)), include_audio: false, ..Default::default() };
|
||||
let (p, seq, m) = project();
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let file = filmcraft_isobmff::open(bytes.as_slice()).unwrap();
|
||||
let vt = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).unwrap();
|
||||
assert_eq!(file.tracks[vt].samples.len(), 24);
|
||||
for i in 0..file.tracks[vt].samples.len() {
|
||||
let sample = file.read_sample(bytes.as_slice(), vt, i).unwrap();
|
||||
assert_eq!(slices_in(&sample), 64, "picture {i}");
|
||||
}
|
||||
}
|
||||
+47
-18
@@ -11,6 +11,10 @@
|
||||
//! When a source's bytes are not available yet (asynchronous web reads mark
|
||||
//! [`filmcraft_media::pending`]), the batch is rendered again on the next call: nothing is encoded
|
||||
//! from frames or audio with missing media.
|
||||
//!
|
||||
//! The file does not depend on how many frames a step renders (one per core natively, one on the
|
||||
//! web): packets go to the muxer in fixed groups of [`INTERLEAVE`] output frames, each followed by
|
||||
//! the audio up to its end, so the same project and settings give the same bytes on any machine.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -30,6 +34,11 @@ use crate::{
|
||||
audio_factories, export_range, frame_span, video_factories,
|
||||
};
|
||||
|
||||
/// Output frames per interleaved group: the video packets of these frames, then the audio up to
|
||||
/// their end, form one chunk of each track. Fixed (not the batch size, which follows the core
|
||||
/// count) so the file layout is the same on every machine.
|
||||
pub const INTERLEAVE: i64 = 16;
|
||||
|
||||
/// What one [`Exporter::step`] did.
|
||||
#[derive(Debug)]
|
||||
pub enum Step {
|
||||
@@ -58,7 +67,9 @@ pub struct Exporter {
|
||||
measured: bool,
|
||||
/// The first pass of a two-pass encode is running.
|
||||
first_pass: bool,
|
||||
/// Created after the first batch (encoders may finalise their codec config then).
|
||||
/// Encoded video packets not written yet (the current [`INTERLEAVE`] group).
|
||||
queued: Vec<EncodedPacket>,
|
||||
/// Created after the first group was encoded (encoders may finalise their codec config then).
|
||||
mux: Option<Mp4Writer<Out>>,
|
||||
/// MXF exports write through this instead of `mux`.
|
||||
mxf: Option<MxfMux>,
|
||||
@@ -140,6 +151,7 @@ impl Exporter {
|
||||
audio,
|
||||
measured,
|
||||
first_pass: two_pass,
|
||||
queued: Vec::new(),
|
||||
mux: None,
|
||||
mxf: None,
|
||||
seq,
|
||||
@@ -177,7 +189,7 @@ impl Exporter {
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let mux = self.mux.as_mut().expect("mux created");
|
||||
let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?;
|
||||
match self.aenc.as_mut() {
|
||||
Some(a) => {
|
||||
for au in a.encode(&buf)? {
|
||||
@@ -209,7 +221,7 @@ impl Exporter {
|
||||
if let Some(m) = self.mxf.as_mut() {
|
||||
return m.write_video(packets);
|
||||
}
|
||||
let mux = self.mux.as_mut().expect("mux created");
|
||||
let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?;
|
||||
for p in packets {
|
||||
mux.write_sample(self.vt, WriteSample { data: &p.data, duration: p.duration, composition_offset: p.composition_offset, is_sync: p.key })
|
||||
.map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
@@ -270,7 +282,7 @@ impl Exporter {
|
||||
if !self.settings.part_of_batch {
|
||||
progress.set_status("Measuring loudness");
|
||||
}
|
||||
let a = self.audio.as_mut().expect("audio");
|
||||
let a = self.audio.as_mut().ok_or_else(|| ExportError::Encode("internal: the audio pipeline was not created".into()))?;
|
||||
a.measure(&self.settings, sources, &|| progress.cancel.load(Ordering::Relaxed))?;
|
||||
if filmcraft_media::pending::take() {
|
||||
return Ok(Step::Pending);
|
||||
@@ -295,22 +307,33 @@ impl Exporter {
|
||||
self.next = end;
|
||||
return Ok(Step::Progress);
|
||||
}
|
||||
let mixed = match self.audio.as_ref().map(|a| a.sr) {
|
||||
Some(sr) if (self.mux.is_none() && self.mxf.is_none()) || self.at.is_some() || self.mxf.is_some() => {
|
||||
let until = self.sample_at_frame(end, sr);
|
||||
self.audio.as_mut().and_then(|a| a.pull(until, sources))
|
||||
// the audio of every interleave group that ends in this batch (before encoding, so a
|
||||
// pending source can still make the batch run again)
|
||||
let cuts: Vec<i64> = (f + 1..=end).filter(|&g| (g - self.f0) % INTERLEAVE == 0).collect();
|
||||
let mut mixed = Vec::with_capacity(cuts.len());
|
||||
if let Some(sr) = self.audio.as_ref().map(|a| a.sr) {
|
||||
for &g in &cuts {
|
||||
let until = self.sample_at_frame(g, sr);
|
||||
mixed.push(self.audio.as_mut().and_then(|a| a.pull(until, sources)));
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if filmcraft_media::pending::take() {
|
||||
return Ok(Step::Pending);
|
||||
}
|
||||
let packets = self.encode(&frames, f)?;
|
||||
if self.mux.is_none() && self.mxf.is_none() {
|
||||
self.open_mux()?;
|
||||
let mut mixed = mixed.into_iter();
|
||||
for (k, frame) in frames.iter().enumerate() {
|
||||
let fi = f + k as i64;
|
||||
let packets = self.encode(std::slice::from_ref(frame), fi)?;
|
||||
self.queued.extend(packets);
|
||||
if cuts.contains(&(fi + 1)) {
|
||||
if self.mux.is_none() && self.mxf.is_none() {
|
||||
self.open_mux()?;
|
||||
}
|
||||
let packets = std::mem::take(&mut self.queued);
|
||||
self.write_video(packets)?;
|
||||
self.write_audio(mixed.next().flatten())?;
|
||||
}
|
||||
}
|
||||
self.write_video(packets)?;
|
||||
self.write_audio(mixed)?;
|
||||
progress.done.fetch_add((end - f) as u64, Ordering::Relaxed);
|
||||
self.next = end;
|
||||
return Ok(Step::Progress);
|
||||
@@ -332,12 +355,13 @@ impl Exporter {
|
||||
if self.mux.is_none() && self.mxf.is_none() {
|
||||
self.open_mux()?;
|
||||
}
|
||||
let tail = self.venc.flush()?;
|
||||
let mut tail = std::mem::take(&mut self.queued);
|
||||
tail.extend(self.venc.flush()?);
|
||||
self.write_video(tail)?;
|
||||
self.write_audio(mixed)?;
|
||||
if let (Some(at), Some(a)) = (self.at, self.aenc.as_mut()) {
|
||||
let fs = a.frame_size();
|
||||
let mux = self.mux.as_mut().expect("mux created");
|
||||
let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?;
|
||||
for au in a.flush()? {
|
||||
mux.write_sample(at, WriteSample { data: &au, duration: fs, composition_offset: 0, is_sync: true })
|
||||
.map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
@@ -346,7 +370,12 @@ impl Exporter {
|
||||
let (bytes, extra_files) = match self.mxf.take() {
|
||||
Some(m) => m.finish(&self.settings)?,
|
||||
None => {
|
||||
let w = self.mux.take().expect("mux created").finish().map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
let w = self
|
||||
.mux
|
||||
.take()
|
||||
.ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?
|
||||
.finish()
|
||||
.map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
(w.finish(&self.settings)?, Vec::new())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio
|
||||
//! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the
|
||||
//! built-in presets.
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
@@ -916,6 +917,13 @@ pub fn rgbf_to_yuv420_8(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &m
|
||||
});
|
||||
}
|
||||
|
||||
/// Slices per H.264 picture: one per four macroblock rows. The encoder's default follows the core
|
||||
/// count, which would make the stream (and every decoded picture) depend on the machine; this is
|
||||
/// what it picks on a machine with enough cores.
|
||||
pub(crate) fn h264_slices(height: u32) -> usize {
|
||||
(height.div_ceil(16) as usize).div_ceil(4).max(1)
|
||||
}
|
||||
|
||||
fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
|
||||
if format != Format::H264 {
|
||||
return None;
|
||||
@@ -925,6 +933,7 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
|
||||
cfg.format = if s.format.is_mxf() { filmcraft_h264enc::PacketFormat::AnnexB } else { filmcraft_h264enc::PacketFormat::LengthPrefixed };
|
||||
cfg.aud = s.format.is_mxf();
|
||||
cfg.keyint = s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (rate.num as f64 / rate.den as f64 * 2.0).round().max(1.0) as u32);
|
||||
cfg.slices = h264_slices(h);
|
||||
let kbps = s.bitrate_kbps.max(100);
|
||||
let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps * 3 / 2);
|
||||
cfg.rate = match s.bitrate_mode {
|
||||
@@ -1108,7 +1117,10 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
}
|
||||
(total, count)
|
||||
}
|
||||
Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => unreachable!("stepped export"),
|
||||
Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => {
|
||||
// Handled by the stepped exporter above; reaching here would be a dispatch bug.
|
||||
return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format)));
|
||||
}
|
||||
};
|
||||
let secs = t0.elapsed().as_secs_f64();
|
||||
if !settings.part_of_batch {
|
||||
@@ -1129,3 +1141,6 @@ mod surround_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mxf_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod determinism_tests;
|
||||
@@ -303,6 +303,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip with unresolved "auto" points (NaN) is written with `null` coordinates. Such a file
|
||||
/// used to be refused as damaged; it opens, and the points are still "auto".
|
||||
#[test]
|
||||
fn auto_points_written_as_null_open_again() {
|
||||
use filmcraft_project::{ItemKind, SequenceSettings, TrackKind};
|
||||
use filmcraft_time::{FrameRate, Tick, TimeRange};
|
||||
let mut p = Project::new("Imported");
|
||||
let layer = p.add_item(
|
||||
"Layer",
|
||||
filmcraft_project::Label::Iris,
|
||||
ItemKind::AdjustmentLayer { width: 3840, height: 2160, rate: FrameRate::FPS_24, duration: FrameRate::FPS_24.tick_of(48) },
|
||||
None,
|
||||
);
|
||||
let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None);
|
||||
let ti = p.make_track_item(layer, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(48)), FrameRate::FPS_24).unwrap();
|
||||
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(ti);
|
||||
for pretty in [true, false] {
|
||||
let bytes = encode(&p, pretty);
|
||||
let text = String::from_utf8(bytes.clone()).unwrap();
|
||||
assert!(text.replace([' ', '\n'], "").contains(r#"{"Vec2":{"x":null,"y":null}}"#), "the file holds NaN points as null");
|
||||
let l = decode(&bytes).unwrap_or_else(|e| panic!("a file with `null` points is refused: {e}"));
|
||||
let motion = l.project.sequence(seq).unwrap().video_tracks[0].items[0].effect("motion").unwrap().clone();
|
||||
let anchor = motion.params["anchor"].value.as_vec2().unwrap();
|
||||
assert!(anchor.x.is_nan() && anchor.y.is_nan(), "still auto");
|
||||
assert_eq!(motion.params["scale"].value.as_f64(), Some(100.0));
|
||||
assert_eq!(encode(&l.project, pretty), bytes, "and the file is written back the same");
|
||||
// a `null` where no "auto" exists is still a damaged file
|
||||
let broken = text.replacen("3840", "null", 1);
|
||||
assert!(matches!(decode(broken.as_bytes()), Err(FormatError::Corrupt(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_has_explicit_schema_version() {
|
||||
let v: Value = serde_json::from_slice(&encode(&Project::new("x"), true)).unwrap();
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod pool;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_color::{ColorInfo, DecodeTable, Matrix, Range, linear_to_srgb_u8, normalize_c, normalize_y, srgb_u8_to_linear_table, to_linear, ycbcr_to_rgb};
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Recycled plane buffers.
|
||||
//!
|
||||
//! A decoder allocates three planes per picture and the caches free them a few hundred frames
|
||||
//! later. At megabytes per plane that traffic leaves the system allocator holding gigabytes of
|
||||
//! freed pages (measured: 2.5 GB retained next to 2.5 GB live after twelve clips). Decoders take
|
||||
//! their planes here instead ([`take_u8`], [`take_u16`]) and caches hand evicted frames back
|
||||
//! ([`recycle`]), so steady-state decoding allocates nothing.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use crate::{PixelData, VideoFrame};
|
||||
|
||||
/// Bytes of idle buffers kept per sample type; a buffer that does not fit is freed.
|
||||
const MAX_BYTES: usize = 192 << 20;
|
||||
|
||||
/// Smaller buffers are not worth keeping (the allocator recycles those well).
|
||||
const MIN_BYTES: usize = 64 << 10;
|
||||
|
||||
struct Shelf<T> {
|
||||
bufs: Vec<Vec<T>>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl<T> Shelf<T> {
|
||||
const fn new() -> Self {
|
||||
Self { bufs: Vec::new(), bytes: 0 }
|
||||
}
|
||||
|
||||
fn size(b: &Vec<T>) -> usize {
|
||||
b.capacity().saturating_mul(std::mem::size_of::<T>())
|
||||
}
|
||||
|
||||
/// The smallest idle buffer that holds `len` samples without wasting over a quarter of it.
|
||||
fn take(&mut self, len: usize) -> Option<Vec<T>> {
|
||||
let k = self
|
||||
.bufs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| b.capacity() >= len && b.capacity() - len <= len / 4)
|
||||
.min_by_key(|(_, b)| b.capacity())
|
||||
.map(|(k, _)| k)?;
|
||||
let b = self.bufs.swap_remove(k);
|
||||
self.bytes = self.bytes.saturating_sub(Self::size(&b));
|
||||
Some(b)
|
||||
}
|
||||
|
||||
fn give(&mut self, mut b: Vec<T>, max_bytes: usize) {
|
||||
let n = Self::size(&b);
|
||||
if n < MIN_BYTES || self.bytes.saturating_add(n) > max_bytes {
|
||||
return;
|
||||
}
|
||||
b.clear();
|
||||
self.bytes += n;
|
||||
self.bufs.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
static U8: Mutex<Shelf<u8>> = Mutex::new(Shelf::new());
|
||||
static U16: Mutex<Shelf<u16>> = Mutex::new(Shelf::new());
|
||||
static REUSED: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn take<T>(shelf: &Mutex<Shelf<T>>, len: usize) -> Vec<T> {
|
||||
let found = shelf.lock().unwrap_or_else(PoisonError::into_inner).take(len);
|
||||
match found {
|
||||
Some(b) => {
|
||||
REUSED.fetch_add(1, Ordering::Relaxed);
|
||||
b
|
||||
}
|
||||
None => Vec::with_capacity(len),
|
||||
}
|
||||
}
|
||||
|
||||
fn give<T>(shelf: &Mutex<Shelf<T>>, plane: Arc<Vec<T>>) {
|
||||
// a plane something else still shows (a monitor, a GPU upload in flight) is theirs
|
||||
if let Ok(b) = Arc::try_unwrap(plane) {
|
||||
shelf.lock().unwrap_or_else(PoisonError::into_inner).give(b, MAX_BYTES);
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty buffer with room for `len` samples: a recycled one when one fits.
|
||||
pub fn take_u8(len: usize) -> Vec<u8> {
|
||||
take(&U8, len)
|
||||
}
|
||||
|
||||
/// [`take_u8`] for 16-bit planes.
|
||||
pub fn take_u16(len: usize) -> Vec<u16> {
|
||||
take(&U16, len)
|
||||
}
|
||||
|
||||
/// Hand back a frame nobody needs any more: its planes are kept for the next [`take_u8`] /
|
||||
/// [`take_u16`] unless something else still holds the frame or a plane.
|
||||
pub fn recycle(frame: Arc<VideoFrame>) {
|
||||
let Ok(f) = Arc::try_unwrap(frame) else { return };
|
||||
match f.data {
|
||||
PixelData::Rgba8(d) => give(&U8, d),
|
||||
PixelData::RgbaF32(_) => {}
|
||||
PixelData::Yuv8 { planes, alpha, .. } => planes.into_iter().chain(alpha).for_each(|p| give(&U8, p)),
|
||||
PixelData::Yuv16 { planes, alpha, .. } => planes.into_iter().chain(alpha).for_each(|p| give(&U16, p)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Idle buffers held and buffers handed out again so far (`perf.stats`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PoolStats {
|
||||
pub idle_bytes: usize,
|
||||
pub reused: u64,
|
||||
}
|
||||
|
||||
pub fn stats() -> PoolStats {
|
||||
let idle = |n: usize, m: usize| n.saturating_add(m);
|
||||
let a = U8.lock().unwrap_or_else(PoisonError::into_inner).bytes;
|
||||
let b = U16.lock().unwrap_or_else(PoisonError::into_inner).bytes;
|
||||
PoolStats { idle_bytes: idle(a, b), reused: REUSED.load(Ordering::Relaxed) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Chroma;
|
||||
|
||||
#[test]
|
||||
fn shelf_hands_back_a_fitting_buffer_and_stays_within_its_budget() {
|
||||
let mut s = Shelf::<u8>::new();
|
||||
s.give(Vec::with_capacity(1 << 20), 3 << 20);
|
||||
s.give(Vec::with_capacity(2 << 20), 3 << 20);
|
||||
assert_eq!((s.bufs.len(), s.bytes), (2, 3 << 20));
|
||||
// over the budget and too small to keep: freed
|
||||
s.give(Vec::with_capacity(1 << 20), 3 << 20);
|
||||
s.give(Vec::with_capacity(1024), 3 << 20);
|
||||
assert_eq!(s.bufs.len(), 2);
|
||||
// nothing fits a far smaller or a larger request
|
||||
assert!(s.take(100 << 10).is_none());
|
||||
assert!(s.take(3 << 20).is_none());
|
||||
// the smallest buffer that fits, with its contents gone
|
||||
let b = s.take((1 << 20) - 4096).expect("fits");
|
||||
assert_eq!((b.len(), b.capacity()), (0, 1 << 20));
|
||||
assert_eq!((s.bufs.len(), s.bytes), (1, 2 << 20));
|
||||
assert!(s.take(1 << 20).is_none(), "2 MiB wastes too much for 1 MiB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycled_frames_feed_the_next_take_unless_still_shared() {
|
||||
// sizes nothing else in this test binary asks for
|
||||
let (n, c) = (777_001usize, 333_003usize);
|
||||
let yuv = |fill: u8| {
|
||||
Arc::new(VideoFrame {
|
||||
width: 1,
|
||||
height: 1,
|
||||
data: PixelData::Yuv8 {
|
||||
planes: [Arc::new(vec![fill; n]), Arc::new(vec![fill; c]), Arc::new(vec![fill; c])],
|
||||
chroma: Chroma::C420,
|
||||
alpha: None,
|
||||
},
|
||||
color: filmcraft_color::ColorInfo::SRGB_FULL,
|
||||
par: (1, 1),
|
||||
pts: filmcraft_time::Tick::ZERO,
|
||||
})
|
||||
};
|
||||
let before = stats().reused;
|
||||
// a frame someone else still holds is left alone
|
||||
let shared = yuv(1);
|
||||
let held = shared.clone();
|
||||
recycle(shared);
|
||||
assert!(take_u8(n).capacity() >= n);
|
||||
assert_eq!(stats().reused, before);
|
||||
assert_eq!(held.byte_size(), n + 2 * c);
|
||||
// an unshared frame's planes come back, empty
|
||||
recycle(yuv(2));
|
||||
let (y, u, v) = (take_u8(n), take_u8(c), take_u8(c));
|
||||
assert!(y.is_empty() && y.capacity() >= n && u.capacity() >= c && v.capacity() >= c);
|
||||
assert_eq!(stats().reused, before + 3);
|
||||
// and are not handed out twice
|
||||
take_u8(n);
|
||||
assert_eq!(stats().reused, before + 3);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ mod intra;
|
||||
mod mbtypes;
|
||||
pub mod params;
|
||||
mod picture;
|
||||
pub use picture::set_plane_allocator;
|
||||
pub mod slice;
|
||||
mod slicedec;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -255,14 +255,15 @@ impl Frame {
|
||||
/// Crop and copy the frame into planar buffers (waits for completion).
|
||||
pub fn copy_cropped(&self, crop: (usize, usize, usize, usize)) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let (cx, cy, cw, ch) = crop;
|
||||
let mut y = Vec::with_capacity(cw * ch);
|
||||
let alloc = plane_allocator();
|
||||
let mut y = alloc(cw * ch);
|
||||
for r in cy..cy + ch {
|
||||
let row = self.row(r >> 4);
|
||||
y.extend_from_slice(&row.y[(r & 15) * self.width + cx..(r & 15) * self.width + cx + cw]);
|
||||
}
|
||||
let (ccx, ccy, ccw, cch) = (cx / 2, cy / 2, cw.div_ceil(2), ch.div_ceil(2));
|
||||
let mut u = Vec::with_capacity(ccw * cch);
|
||||
let mut v = Vec::with_capacity(ccw * cch);
|
||||
let mut u = alloc(ccw * cch);
|
||||
let mut v = alloc(ccw * cch);
|
||||
for r in ccy..ccy + cch {
|
||||
let row = self.row(r >> 3);
|
||||
let o = (r & 7) * self.cwidth + ccx;
|
||||
@@ -273,6 +274,18 @@ impl Frame {
|
||||
}
|
||||
}
|
||||
|
||||
static PLANE_ALLOCATOR: std::sync::OnceLock<fn(usize) -> Vec<u8>> = std::sync::OnceLock::new();
|
||||
|
||||
/// Where output planes come from: an empty buffer with room for the given number of samples.
|
||||
/// The host sets it once to recycle the planes of pictures it is done with; the first call wins.
|
||||
pub fn set_plane_allocator(alloc: fn(usize) -> Vec<u8>) {
|
||||
let _ = PLANE_ALLOCATOR.set(alloc);
|
||||
}
|
||||
|
||||
fn plane_allocator() -> fn(usize) -> Vec<u8> {
|
||||
PLANE_ALLOCATOR.get().copied().unwrap_or(Vec::with_capacity)
|
||||
}
|
||||
|
||||
/// Copy `w` samples of `line` starting at x0 into `out`; when possible a fixed-size block of `N`
|
||||
/// bytes is copied instead (cheaper than a variable-length copy; `out` must then have room for `N`).
|
||||
#[inline(always)]
|
||||
|
||||
@@ -39,6 +39,22 @@ Not represented: speed changes and frame holds (exported at 100 % with a report
|
||||
sequences, graphics and synthetic media (gaps), video effects other than transitions, clip
|
||||
markers (written to the master mob, so they come back as media markers), OMF video and markers.
|
||||
|
||||
## Graphics and adjustment layers
|
||||
|
||||
No format here has an equivalent of a FilmCraft graphic (title) clip or an adjustment layer, and
|
||||
none of the exports brings one back. Each such clip is named in the export `Report` as a warning
|
||||
(`graphic clip "Title" at frame 48 …`, one line per clip), with what was written instead:
|
||||
|
||||
| Format | Written as | On import into FilmCraft |
|
||||
|---|---|---|
|
||||
| FCP7 XML | a clip item without media (its layers as FilmCraft filter ids) | not read back; the import report names the skipped clip item |
|
||||
| FCPXML | a clip without media | not read back |
|
||||
| OTIO | a clip with a `MissingReference` (effects in the `filmcraft` metadata) | offline media |
|
||||
| EDL | an ordinary event with the reel the reel mode gives | an ordinary clip |
|
||||
| AAF | a gap | nothing |
|
||||
|
||||
OMF carries no video at all.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps,
|
||||
@@ -47,6 +63,9 @@ markers (written to the master mob, so they come back as media markers), OMF vid
|
||||
(sample data and source offsets); separate consolidated files with a video mixdown; OMF round
|
||||
trips with embedded audio, separate AIFF files and breakout; empty sequences; truncation and
|
||||
random corruption never panic.
|
||||
- `tests/uncarried_clips.rs`: every format's export report names each graphic clip and adjustment
|
||||
layer once, as a warning; a sequence without them reports nothing; what a re-import gives back
|
||||
matches the report; the FCP7 XML import names a clip item whose file is not defined.
|
||||
- `src/aaf/tests.rs`: written files checked against the required properties of every class
|
||||
written (Header, Identification, Mobs, slots, components, descriptors), weak references
|
||||
resolving into the dictionary, source clips resolving to mobs and slots, the Edit Protocol
|
||||
|
||||
@@ -301,6 +301,18 @@ pub(crate) fn base_item(p: &Project, id: ItemId) -> ItemId {
|
||||
}
|
||||
}
|
||||
|
||||
/// How an export report names a clip whose source is a graphic (title) or an adjustment layer,
|
||||
/// which most interchange formats cannot carry: `graphic clip "Title" at frame 48`. `None` for
|
||||
/// every other clip. A lost title must be named, not folded into a count.
|
||||
pub(crate) fn uncarried_clip(p: &Project, c: &TrackItem, rate: FrameRate) -> Option<String> {
|
||||
let what = match &p.item(c.item)?.kind {
|
||||
ItemKind::Graphic { .. } => "graphic clip",
|
||||
ItemKind::AdjustmentLayer { .. } => "adjustment layer",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("{what} \"{}\" at frame {}", c.name, rate.frame_at(c.start)))
|
||||
}
|
||||
|
||||
pub(crate) fn generator_of(p: &Project, id: ItemId) -> Option<&Generator> {
|
||||
match &item_media(p, id)?.media {
|
||||
MediaRef::Generator(g) => Some(g),
|
||||
|
||||
@@ -272,7 +272,10 @@ impl Exporter<'_> {
|
||||
return None;
|
||||
}
|
||||
_ => {
|
||||
self.report.warn("graphics, titles and adjustment layers are not exported (left as gaps)");
|
||||
match crate::common::uncarried_clip(self.p, c, self.seq_rate) {
|
||||
Some(clip) => self.report.warn(format!("{clip} is not exported (left as a gap)")),
|
||||
None => self.report.warn("graphics, titles and adjustment layers are not exported (left as gaps)"),
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -764,6 +764,9 @@ pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report:
|
||||
let tr_in = track.transitions.iter().find(|t| t.to == Some(it.id));
|
||||
let tr_out = track.transitions.iter().find(|t| t.from == Some(it.id));
|
||||
let reel = x.reel(it.item, report);
|
||||
if let Some(clip) = crate::common::uncarried_clip(p, it, rate) {
|
||||
report.warn(format!("{clip} cannot be represented in an EDL: it is written as an ordinary event (reel {reel})"));
|
||||
}
|
||||
let mut rec0 = it.start;
|
||||
let mut rec1 = it.end();
|
||||
let mut src0 = it.source_in;
|
||||
|
||||
@@ -380,7 +380,13 @@ impl<'a, 'i> Imp<'a, 'i, '_> {
|
||||
let label = if name.is_empty() { g.label() } else { name.clone() };
|
||||
self.b.generator_media(&key, &label, g, &spec, None)
|
||||
} else if let Some(f) = child(c, "file") {
|
||||
self.file(f, Some(&name), None)?
|
||||
match self.file(f, Some(&name), None) {
|
||||
Some(item) => item,
|
||||
None => {
|
||||
self.report.warn(format!("clip \"{name}\" refers to a file the document does not define; skipped"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else if let Some(s) = child(c, "sequence") {
|
||||
self.sequence(s, None)?
|
||||
} else {
|
||||
@@ -908,6 +914,10 @@ impl Exp<'_, '_> {
|
||||
if !on_frame(rate, c.start) || !on_frame(rate, c.duration) {
|
||||
self.report.warn("sub-frame clip positions were rounded to frames");
|
||||
}
|
||||
if let Some(clip) = crate::common::uncarried_clip(self.p, c, rate) {
|
||||
self.report
|
||||
.warn(format!("{clip} has no Final Cut Pro 7 XML equivalent: it is written as a clip item without media and is not read back on import"));
|
||||
}
|
||||
let base = base_item(self.p, c.item);
|
||||
let generator = crate::common::generator_of(self.p, base).cloned();
|
||||
let is_seq = matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_)));
|
||||
|
||||
@@ -972,7 +972,13 @@ impl Exp<'_, '_> {
|
||||
let origin = self.p.sequence(base).map(|s| s.settings.frame_rate.tick_of(s.start_timecode)).unwrap_or(Tick::ZERO);
|
||||
("ref-clip", r, origin)
|
||||
} else {
|
||||
let r = self.asset(base, rate);
|
||||
let r = match crate::common::uncarried_clip(self.p, it, rate) {
|
||||
Some(clip) => {
|
||||
self.report.warn(format!("{clip} is not supported in FCPXML: it is written as a clip without media and is not read back on import"));
|
||||
String::new()
|
||||
}
|
||||
None => self.asset(base, rate),
|
||||
};
|
||||
let m = item_media(self.p, base);
|
||||
let mrate = m.and_then(|m| m.info.video.as_ref().map(|v| v.frame_rate)).unwrap_or(rate);
|
||||
let st = m.and_then(|m| m.info.start_timecode).map(|f| mrate.tick_of(f)).unwrap_or(Tick::ZERO);
|
||||
|
||||
@@ -279,6 +279,10 @@ impl Exp<'_, '_> {
|
||||
return s;
|
||||
}
|
||||
let _ = generator_of(self.p, base);
|
||||
if let Some(clip) = crate::common::uncarried_clip(self.p, it, rate) {
|
||||
self.report
|
||||
.warn(format!("{clip} has no OTIO equivalent: it is written as a clip with a missing media reference and is read back as offline media"));
|
||||
}
|
||||
let mref = self.media_ref(base, rate);
|
||||
// OTIO source times are in the reference's time (which starts at its available_range start).
|
||||
let origin = item_media(self.p, base).and_then(|m| m.info.start_timecode.map(|f| m.frame_rate().tick_of(f))).unwrap_or(Tick::ZERO);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Graphic (title) clips and adjustment layers have no equivalent in the interchange formats. An
|
||||
//! export used to lose them without a word (FCP7 XML, OTIO, EDL) or with a line that named
|
||||
//! nothing (FCPXML, AAF): every such clip is now named in the export report.
|
||||
|
||||
mod support;
|
||||
|
||||
use filmcraft_interchange::{ExportOptions, Format, Report, export, import};
|
||||
use filmcraft_project::{ItemId, ItemKind, Label, ParamValue, Project, TrackKind, find_effect};
|
||||
use filmcraft_time::FrameRate;
|
||||
use support::*;
|
||||
|
||||
const RATE: FrameRate = FrameRate::FPS_24;
|
||||
|
||||
/// V1: a media clip (frames 0–48) and an adjustment layer (48–72); V2: a title (12–36).
|
||||
fn project_with_a_title() -> (Project, ItemId) {
|
||||
let mut p = Project::new("Titles");
|
||||
let s = sequence(&mut p, "Cut", RATE, false);
|
||||
let a = media(&mut p, "/m/a.mov", true, true, RATE);
|
||||
let graphic = p.add_item("Graphic", Label::Rose, ItemKind::Graphic { width: 1920, height: 1080, rate: RATE }, None);
|
||||
let layer =
|
||||
p.add_item("Adjustment Layer", Label::Iris, ItemKind::AdjustmentLayer { width: 1920, height: 1080, rate: RATE, duration: RATE.tick_of(240) }, None);
|
||||
clip(&mut p, s, TrackKind::Video, 0, a, 0, 48, 0);
|
||||
let adj = clip(&mut p, s, TrackKind::Video, 0, layer, 48, 24, 0);
|
||||
let title = clip(&mut p, s, TrackKind::Video, 1, graphic, 12, 24, 0);
|
||||
let q = p.sequence_mut(s).unwrap();
|
||||
let (_, t) = q.find_item_mut(title).unwrap();
|
||||
t.name = "Opening \"line\"".into();
|
||||
let mut text = find_effect("graphic_text").unwrap().instance();
|
||||
text.params.get_mut("text").unwrap().value = ParamValue::Text("Opening line".into());
|
||||
t.effects.push(text);
|
||||
q.find_item_mut(adj).unwrap().1.name = "Grade".into();
|
||||
q.check().unwrap();
|
||||
(p, s)
|
||||
}
|
||||
|
||||
fn export_report(p: &Project, s: ItemId, format: Format) -> (Vec<u8>, Report) {
|
||||
export(p, s, format, &ExportOptions::default()).unwrap_or_else(|e| panic!("{format:?}: {e}"))
|
||||
}
|
||||
|
||||
const TITLE: &str = "graphic clip \"Opening \"line\"\" at frame 12";
|
||||
const LAYER: &str = "adjustment layer \"Grade\" at frame 48";
|
||||
|
||||
#[test]
|
||||
fn every_format_names_the_title_and_the_adjustment_layer_it_cannot_carry() {
|
||||
let (p, s) = project_with_a_title();
|
||||
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio, Format::Aaf] {
|
||||
let (_, report) = export_report(&p, s, format);
|
||||
for clip in [TITLE, LAYER] {
|
||||
let named: Vec<_> = report.warnings().filter(|e| e.message.starts_with(clip)).collect();
|
||||
assert_eq!(named.len(), 1, "{format:?} names {clip} once, as a warning: {report}");
|
||||
assert_eq!(named[0].count, 1);
|
||||
}
|
||||
}
|
||||
// an EDL holds one video track: V1 by default (the layer), V2 when asked (the title)
|
||||
let (_, report) = export_report(&p, s, Format::Edl);
|
||||
assert!(report.mentions(LAYER) && !report.mentions(TITLE), "{report}");
|
||||
let mut opts = ExportOptions::default();
|
||||
opts.edl.video_track = Some(1);
|
||||
let (_, report) = export(&p, s, Format::Edl, &opts).unwrap();
|
||||
assert!(report.mentions(TITLE), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_titles_are_two_report_lines() {
|
||||
let (mut p, s) = project_with_a_title();
|
||||
let graphic = p.items.values().find(|i| matches!(i.kind, ItemKind::Graphic { .. })).map(|i| i.id).unwrap();
|
||||
let second = clip(&mut p, s, TrackKind::Video, 1, graphic, 40, 10, 0);
|
||||
p.sequence_mut(s).unwrap().find_item_mut(second).unwrap().1.name = "Closing".into();
|
||||
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio, Format::Aaf] {
|
||||
let (_, report) = export_report(&p, s, format);
|
||||
assert!(report.mentions(TITLE) && report.mentions("graphic clip \"Closing\" at frame 40"), "{format:?}: {report}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_without_such_clips_reports_nothing_about_them() {
|
||||
let mut p = Project::new("Plain");
|
||||
let s = sequence(&mut p, "Cut", RATE, false);
|
||||
let a = media(&mut p, "/m/a.mov", true, true, RATE);
|
||||
clip(&mut p, s, TrackKind::Video, 0, a, 0, 48, 0);
|
||||
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio, Format::Edl, Format::Aaf] {
|
||||
let (_, report) = export_report(&p, s, format);
|
||||
assert!(!report.mentions("graphic clip") && !report.mentions("adjustment layer"), "{format:?}: {report}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the report says happens is what happens: the written documents do not bring the title
|
||||
/// back as a graphic.
|
||||
#[test]
|
||||
fn the_reports_match_what_an_import_gives_back() {
|
||||
let (p, s) = project_with_a_title();
|
||||
let graphics = |q: &Project| {
|
||||
q.sequences()
|
||||
.filter_map(|i| i.as_sequence())
|
||||
.flat_map(|q| q.all_tracks())
|
||||
.flat_map(|t| &t.items)
|
||||
.filter(|c| matches!(q.item(c.item).map(|i| &i.kind), Some(ItemKind::Graphic { .. })))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(graphics(&p), 1);
|
||||
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] {
|
||||
let (bytes, _) = export_report(&p, s, format);
|
||||
let (imported, _) = import(&bytes, format, None).unwrap_or_else(|e| panic!("{format:?}: {e}"));
|
||||
assert_eq!(graphics(&imported.project), 0, "{format:?}");
|
||||
let seq = only_seq(&imported);
|
||||
let names: Vec<String> = imported.project.sequence(seq).unwrap().video_tracks.iter().flat_map(|t| &t.items).map(|c| c.name.clone()).collect();
|
||||
// "not read back" (the XML formats) / "read back as offline media" (OTIO)
|
||||
assert_eq!(names.iter().any(|n| n.starts_with("Opening")), format == Format::Otio, "{format:?}: {names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The FCP7 XML importer dropped a clip item whose `<file>` reference has no definition (which is
|
||||
/// how the export writes a title) without a word: the import report names it.
|
||||
#[test]
|
||||
fn fcp7_import_reports_a_clip_item_whose_file_is_not_defined() {
|
||||
let (p, s) = project_with_a_title();
|
||||
let (bytes, _) = export_report(&p, s, Format::Fcp7Xml);
|
||||
let (imported, report) = import(&bytes, Format::Fcp7Xml, None).unwrap();
|
||||
for name in ["Opening \"line\"", "Grade"] {
|
||||
let line = format!("clip \"{name}\" refers to a file the document does not define; skipped");
|
||||
assert_eq!(report.warnings().filter(|e| e.message == line).count(), 1, "{report}");
|
||||
}
|
||||
let seq = imported.project.sequence(only_seq(&imported)).unwrap();
|
||||
let names: Vec<&str> = seq.video_tracks.iter().flat_map(|t| &t.items).map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["a.mov"], "the clip with media is imported as before");
|
||||
// a hand-written reference to a file id that is never defined
|
||||
let doc = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xmeml version="4"><sequence id="s"><name>Cut</name><duration>48</duration><rate><timebase>24</timebase><ntsc>FALSE</ntsc></rate>
|
||||
<media><video><track>
|
||||
<clipitem id="c1"><name>ghost</name><start>0</start><end>24</end><in>0</in><out>24</out><file id="nowhere"/></clipitem>
|
||||
<clipitem id="c2"><name>real</name><start>24</start><end>48</end><in>0</in><out>24</out><file id="f1"><name>real.mov</name><pathurl>real.mov</pathurl></file></clipitem>
|
||||
</track></video></media></sequence></xmeml>"#;
|
||||
let (imported, report) = import(doc.as_bytes(), Format::Fcp7Xml, None).unwrap();
|
||||
assert!(report.mentions("clip \"ghost\" refers to a file the document does not define; skipped"), "{report}");
|
||||
assert!(!report.mentions("\"real\""), "{report}");
|
||||
assert_eq!(imported.project.sequence(only_seq(&imported)).unwrap().video_tracks[0].items.len(), 1);
|
||||
// a document whose files are all defined says nothing of the kind
|
||||
let mut plain = Project::new("Plain");
|
||||
let s = sequence(&mut plain, "Cut", RATE, false);
|
||||
let a = media(&mut plain, "/m/a.mov", true, true, RATE);
|
||||
clip(&mut plain, s, TrackKind::Video, 0, a, 0, 48, 0);
|
||||
clip(&mut plain, s, TrackKind::Video, 0, a, 48, 24, 0);
|
||||
let (bytes, _) = export_report(&plain, s, Format::Fcp7Xml);
|
||||
let (_, report) = import(&bytes, Format::Fcp7Xml, None).unwrap();
|
||||
assert!(!report.mentions("does not define"), "{report}");
|
||||
}
|
||||
@@ -43,6 +43,57 @@ impl ByteReader for MemReader {
|
||||
}
|
||||
}
|
||||
|
||||
/// A file on disk, read in place: the file stays open and only the requested ranges are read, so
|
||||
/// opening a clip costs its index rather than its size.
|
||||
#[cfg(any(unix, windows))]
|
||||
#[derive(Debug)]
|
||||
pub struct FileReader {
|
||||
file: std::fs::File,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
impl FileReader {
|
||||
pub fn open(path: &std::path::Path) -> io::Result<Self> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let m = file.metadata()?;
|
||||
if !m.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, format!("{} is not a file", path.display())));
|
||||
}
|
||||
Ok(Self { file, len: m.len() })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
impl ByteReader for FileReader {
|
||||
fn len(&self) -> u64 {
|
||||
self.len
|
||||
}
|
||||
/// Positional reads: threads decoding different parts of the file do not share a cursor.
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let (mut done, mut at) = (0usize, offset);
|
||||
while let Some(rest) = buf.get_mut(done..).filter(|r| !r.is_empty()) {
|
||||
match std::os::windows::fs::FileExt::seek_read(&self.file, rest, at) {
|
||||
Ok(0) => return Err(eof()),
|
||||
Ok(n) => {
|
||||
done = done.saturating_add(n);
|
||||
at = at.saturating_add(n as u64);
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `len` bytes at `offset` (fewer at the end of the reader).
|
||||
pub fn read_range(r: &dyn ByteReader, offset: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||
let n = (r.len().saturating_sub(offset)).min(len as u64) as usize;
|
||||
@@ -85,6 +136,28 @@ mod tests {
|
||||
assert_eq!(read_range(&r, 8, 100).unwrap(), b"89");
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn file_reader_reads_ranges_in_place() {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-file-reader-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("bytes.bin");
|
||||
std::fs::write(&path, b"0123456789").unwrap();
|
||||
let r = FileReader::open(&path).unwrap();
|
||||
assert_eq!(r.len(), 10);
|
||||
let mut b = [0u8; 3];
|
||||
r.read_at(4, &mut b).unwrap();
|
||||
assert_eq!(&b, b"456");
|
||||
// reads are positional: an earlier offset after a later one
|
||||
r.read_at(0, &mut b).unwrap();
|
||||
assert_eq!(&b, b"012");
|
||||
assert_eq!(r.read_at(8, &mut b).unwrap_err().kind(), io::ErrorKind::UnexpectedEof);
|
||||
assert_eq!(read_range(&r, 8, 100).unwrap(), b"89");
|
||||
assert!(FileReader::open(&dir).is_err(), "a directory is not a media file");
|
||||
assert_eq!(FileReader::open(&dir.join("missing")).unwrap_err().kind(), io::ErrorKind::NotFound);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_byte_openers() {
|
||||
// a WAV is opened through the whole-file path
|
||||
|
||||
@@ -53,6 +53,18 @@ pub fn register() -> Availability {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`register`] has put a hardware decoder factory in front of our decoders.
|
||||
pub fn registered() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
filmcraft_codecs::video_decoder_registered(videotoolbox_factory)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this system's hardware decoder takes the stream of `entry` (whatever the Hardware
|
||||
/// decoding setting says): diagnostics and tests.
|
||||
pub fn hardware_decoder_for(entry: &filmcraft_isobmff::SampleEntry) -> bool {
|
||||
|
||||
@@ -22,7 +22,7 @@ use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use filmcraft_codecs::hw::{NalCodec, NalStreamInfo};
|
||||
use filmcraft_codecs::{CodecError, DecodedFrame, Result, VideoDecoder};
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame, pool};
|
||||
use objc2_core_foundation::{CFBoolean, CFDictionary, CFNumber, CFRetained, CFString, CFType};
|
||||
use objc2_core_media::{
|
||||
CMBlockBuffer, CMFormatDescription, CMSampleBuffer, CMSampleTimingInfo, CMTime, CMTimeFlags, CMVideoFormatDescriptionCreateFromH264ParameterSets,
|
||||
@@ -216,11 +216,11 @@ fn copy_out(pb: &CVImageBuffer, l: &Layout) -> std::result::Result<VideoFrame, S
|
||||
return Err("decoded picture planes are smaller than the picture".into());
|
||||
}
|
||||
let data = if bps == 1 {
|
||||
let mut yp = Vec::with_capacity(w * h);
|
||||
let mut yp = pool::take_u8(w * h);
|
||||
for y in 0..h {
|
||||
yp.extend_from_slice(ys.row(oy + y, ox * bps, w * bps)?);
|
||||
}
|
||||
let (mut u, mut v) = (Vec::with_capacity(cw * ch), Vec::with_capacity(cw * ch));
|
||||
let (mut u, mut v) = (pool::take_u8(cw * ch), pool::take_u8(cw * ch));
|
||||
for y in 0..ch {
|
||||
for pair in cs.row(coy + y, cox * 2 * bps, cw * 2 * bps)?.as_chunks::<2>().0 {
|
||||
u.push(pair[0]);
|
||||
@@ -230,11 +230,11 @@ fn copy_out(pb: &CVImageBuffer, l: &Layout) -> std::result::Result<VideoFrame, S
|
||||
PixelData::Yuv8 { planes: [Arc::new(yp), Arc::new(u), Arc::new(v)], chroma: l.chroma, alpha: None }
|
||||
} else {
|
||||
let shift = 16 - l.bits;
|
||||
let mut yp = Vec::with_capacity(w * h);
|
||||
let mut yp = pool::take_u16(w * h);
|
||||
for y in 0..h {
|
||||
yp.extend(ys.row(oy + y, ox * bps, w * bps)?.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes([b[0], b[1]]) >> shift));
|
||||
}
|
||||
let (mut u, mut v) = (Vec::with_capacity(cw * ch), Vec::with_capacity(cw * ch));
|
||||
let (mut u, mut v) = (pool::take_u16(cw * ch), pool::take_u16(cw * ch));
|
||||
for y in 0..ch {
|
||||
for q in cs.row(coy + y, cox * 2 * bps, cw * 2 * bps)?.as_chunks::<4>().0 {
|
||||
u.push(u16::from_ne_bytes([q[0], q[1]]) >> shift);
|
||||
|
||||
@@ -11,7 +11,11 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ParamValue {
|
||||
Float(f64),
|
||||
Vec2(Vec2),
|
||||
/// A point. A coordinate may be NaN: point parameters use NaN for "auto" (the frame or source
|
||||
/// centre) until [`crate::resolve_auto_points`] fills it in. JSON has no NaN and writes it as
|
||||
/// `null`, so `null` reads back as NaN here, and only here: a `null` coordinate anywhere else
|
||||
/// in a project (a mask vertex, a layer rectangle) is still a damaged file.
|
||||
Vec2(#[serde(deserialize_with = "point_or_auto")] Vec2),
|
||||
Color([f32; 4]),
|
||||
Bool(bool),
|
||||
Choice(u32),
|
||||
@@ -22,6 +26,22 @@ pub enum ParamValue {
|
||||
Path(crate::mask::MaskPath),
|
||||
}
|
||||
|
||||
/// Read a point parameter's value, taking a `null` coordinate as NaN ("auto").
|
||||
fn point_or_auto<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec2, D::Error> {
|
||||
fn nan_if_null<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Point {
|
||||
#[serde(deserialize_with = "nan_if_null")]
|
||||
x: f64,
|
||||
#[serde(deserialize_with = "nan_if_null")]
|
||||
y: f64,
|
||||
}
|
||||
let p = Point::deserialize(d)?;
|
||||
Ok(Vec2::new(p.x, p.y))
|
||||
}
|
||||
|
||||
impl ParamValue {
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
|
||||
+152
-1
@@ -1335,9 +1335,15 @@ impl Project {
|
||||
|
||||
/// Resolve NaN "auto" point defaults (frame centre / source centre) in an effect instance.
|
||||
pub fn resolve_auto_points(e: &mut EffectInstance, frame: (u32, u32), source: (u32, u32)) {
|
||||
resolve_auto_points_sized(e, frame, Some(source));
|
||||
}
|
||||
|
||||
/// [`resolve_auto_points`] for a source whose size may not be known yet (`None`): the points
|
||||
/// measured in the source (`anchor`) then stay "auto".
|
||||
fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source: Option<(u32, u32)>) {
|
||||
for (k, p) in e.params.iter_mut() {
|
||||
if let ParamValue::Vec2(v) = &mut p.value {
|
||||
let (w, h) = if k == "anchor" { source } else { frame };
|
||||
let Some((w, h)) = (if k == "anchor" { source } else { Some(frame) }) else { continue };
|
||||
if let Some((fx, fy)) = effect::auto_point(&e.effect, k) {
|
||||
if v.x.is_nan() {
|
||||
v.x = w as f64 * fx;
|
||||
@@ -1357,6 +1363,79 @@ pub fn resolve_auto_points(e: &mut EffectInstance, frame: (u32, u32), source: (u
|
||||
let _ = Vec2::ZERO;
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Size in pixels of what an item shows: a media clip's picture, a sequence's frame, an
|
||||
/// adjustment layer or graphic; a subclip has the size of its parent. `None` without
|
||||
/// picture. This is the size the renderer centres a clip's "auto" anchor in.
|
||||
pub fn source_size(&self, item: ItemId) -> Option<(u32, u32)> {
|
||||
let mut id = item;
|
||||
// subclips of subclips: bounded, a damaged project could point a subclip at itself
|
||||
for _ in 0..16 {
|
||||
return match &self.item(id)?.kind {
|
||||
ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)),
|
||||
ItemKind::Sequence(q) => Some((q.settings.width, q.settings.height)),
|
||||
ItemKind::AdjustmentLayer { width, height, .. } | ItemKind::Graphic { width, height, .. } => Some((*width, *height)),
|
||||
ItemKind::Subclip { parent, .. } => {
|
||||
id = *parent;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve the "auto" (NaN) points of the clips in the sequences `sequences` selects, for
|
||||
/// clips that did not go through a placing command (an interchange import builds them with
|
||||
/// the effect defaults). Points measured in the frame use the sequence's size. `anchor` is
|
||||
/// measured in the clip's source ([`Project::source_size`], a subclip through its parent) and
|
||||
/// is resolved only when `size_is_final` says so for that source item (the media item for a
|
||||
/// subclip); otherwise it stays "auto", which the renderer centres in whatever size the media
|
||||
/// turns out to have. Points that are not NaN are never touched, so this can run again when
|
||||
/// more sizes are known.
|
||||
pub fn resolve_placed_auto_points(&mut self, sequences: impl Fn(ItemId) -> bool, size_is_final: impl Fn(&ProjectItem) -> bool) {
|
||||
let seq_ids: Vec<ItemId> = self.items.values().filter(|i| matches!(i.kind, ItemKind::Sequence(_)) && sequences(i.id)).map(|i| i.id).collect();
|
||||
for seq_id in seq_ids {
|
||||
let Some(seq) = self.sequence(seq_id) else { continue };
|
||||
// the source size of each clip that has an unresolved point, looked up before the edit
|
||||
let sources: BTreeMap<ClipId, Option<(u32, u32)>> = seq
|
||||
.all_tracks()
|
||||
.flat_map(|t| t.items.iter())
|
||||
.filter(|c| c.effects.iter().any(has_auto_point))
|
||||
.map(|c| (c.id, self.final_source_size(c.item, &size_is_final)))
|
||||
.collect();
|
||||
if sources.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(seq) = self.sequence_mut(seq_id) else { continue };
|
||||
let frame = (seq.settings.width, seq.settings.height);
|
||||
for clip in seq.all_tracks_mut().flat_map(|t| t.items.iter_mut()) {
|
||||
let Some(source) = sources.get(&clip.id) else { continue };
|
||||
for e in &mut clip.effects {
|
||||
resolve_auto_points_sized(e, frame, *source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Project::source_size`] of `item` when the item that carries the size (the media item of
|
||||
/// a subclip) passes `size_is_final`.
|
||||
fn final_source_size(&self, item: ItemId, size_is_final: &impl Fn(&ProjectItem) -> bool) -> Option<(u32, u32)> {
|
||||
let mut id = item;
|
||||
for _ in 0..16 {
|
||||
let it = self.item(id)?;
|
||||
match &it.kind {
|
||||
ItemKind::Subclip { parent, .. } => id = *parent,
|
||||
_ => return if size_is_final(it) { self.source_size(id) } else { None },
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn has_auto_point(e: &EffectInstance) -> bool {
|
||||
e.params.values().any(|p| matches!(&p.value, ParamValue::Vec2(v) if v.x.is_nan() || v.y.is_nan()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1401,6 +1480,78 @@ mod tests {
|
||||
assert!(q.sequence(seq).unwrap().check().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_params_keep_auto_through_json_and_no_other_point_reads_null() {
|
||||
// serde_json writes NaN as `null`; a point parameter reads it back as NaN ("auto")
|
||||
let json = serde_json::to_string(&ParamValue::Vec2(Vec2::new(f64::NAN, 540.0))).unwrap();
|
||||
assert_eq!(json, r#"{"Vec2":{"x":null,"y":540.0}}"#);
|
||||
let v = serde_json::from_str::<ParamValue>(&json).unwrap().as_vec2().unwrap();
|
||||
assert!(v.x.is_nan() && v.y == 540.0);
|
||||
let both = serde_json::from_str::<ParamValue>(r#"{"Vec2":{"x":null,"y":null}}"#).unwrap().as_vec2().unwrap();
|
||||
assert!(both.x.is_nan() && both.y.is_nan());
|
||||
assert_eq!(serde_json::from_str::<ParamValue>(r#"{"Vec2":{"x":1.5,"y":-2}}"#).unwrap(), ParamValue::Vec2(Vec2::new(1.5, -2.0)));
|
||||
// anything else is still an error, and so is a missing coordinate
|
||||
for bad in [r#"{"Vec2":{"x":"a","y":0}}"#, r#"{"Vec2":{"x":0}}"#, r#"{"Vec2":null}"#, r#"{"Float":null}"#] {
|
||||
assert!(serde_json::from_str::<ParamValue>(bad).is_err(), "{bad}");
|
||||
}
|
||||
// only point parameters: a `null` coordinate in a mask vertex (or any other point) is damage
|
||||
assert!(serde_json::from_str::<Vec2>(r#"{"x":null,"y":0}"#).is_err());
|
||||
let mut path = serde_json::to_value(MaskPath::ellipse(Vec2::new(100.0, 100.0), Vec2::new(50.0, 40.0))).unwrap();
|
||||
assert!(serde_json::from_value::<MaskPath>(path.clone()).is_ok());
|
||||
path["vertices"][0]["p"]["x"] = serde_json::Value::Null;
|
||||
assert!(serde_json::from_value::<MaskPath>(path.clone()).is_err());
|
||||
assert!(serde_json::from_value::<ParamValue>(serde_json::json!({ "Path": path })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_auto_points_resolve_once_the_source_size_is_final() {
|
||||
let (mut p, clip, seq) = demo_project();
|
||||
let src = p.item(clip).and_then(|i| i.as_media()).and_then(|m| m.info.video.clone()).unwrap();
|
||||
let rate = p.sequence(seq).unwrap().settings.frame_rate;
|
||||
let second = TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND));
|
||||
let sub = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent: clip, range: second, restrict_trims: true }, None);
|
||||
let sub_of_sub = p.add_item("Sub 2", Label::Iris, ItemKind::Subclip { parent: sub, range: second, restrict_trims: true }, None);
|
||||
assert_eq!(p.source_size(clip), Some((src.width, src.height)));
|
||||
assert_eq!((p.source_size(sub), p.source_size(sub_of_sub)), (p.source_size(clip), p.source_size(clip)), "a subclip has its parent's size");
|
||||
assert_eq!(p.source_size(ItemId(987_654_321)), None);
|
||||
// clips whose Motion position / anchor were never resolved (as an interchange import builds them)
|
||||
let mut ids = Vec::new();
|
||||
for (n, item) in [clip, sub, sub_of_sub].into_iter().enumerate() {
|
||||
let ti = p.make_track_item(item, TrackKind::Video, Tick(n as i64 * TICKS_PER_SECOND), second, rate).unwrap();
|
||||
assert!(ti.effect("motion").unwrap().vec2_at("position", Tick::ZERO).x.is_nan());
|
||||
ids.push(ti.id);
|
||||
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(ti);
|
||||
}
|
||||
let st = p.sequence(seq).unwrap().settings.clone();
|
||||
let point = |p: &Project, id: ClipId, k: &str| p.sequence(seq).unwrap().find_item(id).unwrap().1.effect("motion").unwrap().vec2_at(k, Tick::ZERO);
|
||||
let (frame_centre, source_centre) =
|
||||
(Vec2::new(st.width as f64 / 2.0, st.height as f64 / 2.0), Vec2::new(src.width as f64 / 2.0, src.height as f64 / 2.0));
|
||||
// sequences that are not selected are left alone
|
||||
let mut untouched = p.clone();
|
||||
untouched.resolve_placed_auto_points(|_| false, |_| true);
|
||||
assert!(point(&untouched, ids[0], "position").x.is_nan());
|
||||
// the source size is not final yet: the frame's points resolve, the anchor stays auto
|
||||
p.resolve_placed_auto_points(|_| true, |_| false);
|
||||
for id in &ids {
|
||||
assert_eq!(point(&p, *id, "position"), frame_centre);
|
||||
assert!(point(&p, *id, "anchor").x.is_nan() && point(&p, *id, "anchor").y.is_nan());
|
||||
}
|
||||
// ...and survives a save: NaN is written as `null` and read back as NaN
|
||||
let mut q = Project::from_json(&p.to_json()).unwrap();
|
||||
assert!(point(&q, ids[0], "anchor").x.is_nan());
|
||||
assert_eq!(q.to_json(), p.to_json());
|
||||
// final for the media item only: its clip and the clips of its subclips get the source centre
|
||||
q.resolve_placed_auto_points(|_| true, |i| i.id == clip);
|
||||
for id in &ids {
|
||||
assert_eq!((point(&q, *id, "position"), point(&q, *id, "anchor")), (frame_centre, source_centre));
|
||||
}
|
||||
assert!(!q.to_json().contains("null}}"), "no NaN point is left to write");
|
||||
// running it again changes nothing
|
||||
let once = q.clone();
|
||||
q.resolve_placed_auto_points(|_| true, |_| true);
|
||||
assert_eq!(q, once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_at_and_source_time() {
|
||||
let (mut p, clip, seq) = demo_project();
|
||||
|
||||
@@ -850,6 +850,22 @@ mod tests {
|
||||
assert!((l2.lines[1].baseline - l2.lines[0].baseline - gap - 10.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_vertical_titles_with_an_installed_font() {
|
||||
// Japanese glyphs come from a system font (none is bundled): skip where none is installed.
|
||||
crate::fonts::scan_system();
|
||||
if !crate::fonts::all_faces().iter().any(|f| "日本語縦書き".chars().all(|c| f.has_char(c))) {
|
||||
eprintln!("SKIPPED: no Japanese font installed");
|
||||
return;
|
||||
}
|
||||
let l = layout("日本語\n縦書き", &st(40.0), &ParagraphStyle { vertical: true, ..Default::default() });
|
||||
assert!(l.vertical);
|
||||
assert_eq!(l.glyphs.len(), 6);
|
||||
assert!(l.glyphs.iter().all(|g| g.id != 0));
|
||||
assert!(l.glyphs[1].y > l.glyphs[0].y);
|
||||
assert!(l.glyphs[3].x < l.glyphs[0].x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_text_stacks_characters_in_columns() {
|
||||
let p = ParagraphStyle { vertical: true, ..Default::default() };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! live in the native menu bar (set up by the app); elsewhere a compact in-window menu bar follows
|
||||
//! the mode tabs.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use egui::{Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::icons::{self, Icon};
|
||||
@@ -54,6 +54,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
x += gw + 24.0;
|
||||
}
|
||||
let mut left_end = x;
|
||||
// In-window menus when there is no native menu bar.
|
||||
if app.ui.show_menu_bar {
|
||||
let menu_rect = Rect::from_min_max(pos2(x + 6.0, rect.min.y + 7.0), pos2(x + 520.0, rect.max.y - 7.0));
|
||||
@@ -62,10 +63,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
mu.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
|
||||
mu.style_mut().visuals.override_text_color = Some(t.text_dim);
|
||||
crate::menus::menu_bar(app, &mut mu);
|
||||
left_end = mu.min_rect().max.x;
|
||||
}
|
||||
// Document title, centred
|
||||
let title = format!("{}{}", app.session.project.name, if app.session.is_dirty() { " - Edited" } else { "" });
|
||||
p.text(pos2(rect.center().x, rect.center().y), Align2::CENTER_CENTER, title, Tokens::ui(14.0), t.tab_text_active);
|
||||
// Right cluster: icons at ~38 pt pitch, then workspace name in caps.
|
||||
let mut rx = rect.max.x - 14.0;
|
||||
let mut btn = |ui: &mut egui::Ui, icon: Icon, id: &str, tip: &str, app: &mut FilmcraftApp| -> egui::Response {
|
||||
@@ -118,6 +119,23 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.painter().rect_filled(r, 12.0, if resp.hovered() { t.accent_hover } else { t.accent });
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
|
||||
ui.painter().galley(pos2(r.min.x + 25.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE);
|
||||
// Localized menus and user titles can be wider than English. Use the actual gap.
|
||||
let left = left_end + 12.0;
|
||||
let right = r.min.x - 12.0;
|
||||
if right > left + 40.0 {
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(title, Tokens::ui(14.0), t.tab_text_active);
|
||||
job.wrap.max_width = right - left;
|
||||
job.wrap.max_rows = 1;
|
||||
let galley = p.layout_job(job);
|
||||
let half = galley.size().x / 2.0;
|
||||
let center = if rect.center().x - half >= left && rect.center().x + half <= right { rect.center().x } else { (left + right) / 2.0 };
|
||||
p.with_clip_rect(Rect::from_min_max(pos2(left, rect.min.y), pos2(right, rect.max.y))).galley(
|
||||
pos2(center - half, rect.center().y - galley.size().y / 2.0),
|
||||
galley,
|
||||
t.tab_text_active,
|
||||
);
|
||||
}
|
||||
|
||||
if resp.clicked() {
|
||||
crate::links::open(ui.ctx(), crate::links::DISCORD);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Interface translations. Command ids, document text and file names remain stable.
|
||||
//! Untranslated labels fall back to English so coverage can grow incrementally.
|
||||
//!
|
||||
//! No Japanese font is bundled: Japanese uses a font already installed on the system
|
||||
//! ([`system_japanese_font`]); without one, switching to Japanese is refused with a message.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
En,
|
||||
Ja,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::En => "English",
|
||||
Self::Ja => "日本語",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(code: &str) -> Option<Self> {
|
||||
match code {
|
||||
"en" => Some(Self::En),
|
||||
"ja" => Some(Self::Ja),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tr(self, text: &str) -> &str {
|
||||
if self == Self::Ja
|
||||
&& let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text)
|
||||
{
|
||||
return japanese;
|
||||
}
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
/// Text every Japanese interface font must cover (menus use kanji, hiragana and katakana).
|
||||
const JAPANESE_SAMPLE: &str = "日本語ファイル編集あア";
|
||||
|
||||
/// Installed families preferred for Japanese interface text, best first (Gothic / sans-serif faces
|
||||
/// read best at menu sizes). Any other installed face that covers [`JAPANESE_SAMPLE`] is used if
|
||||
/// none of these is present.
|
||||
const PREFERRED_JAPANESE: &[&str] = &[
|
||||
"Hiragino Sans",
|
||||
"Hiragino Kaku Gothic ProN",
|
||||
"Hiragino Kaku Gothic Pro",
|
||||
"Yu Gothic UI",
|
||||
"Yu Gothic",
|
||||
"Meiryo UI",
|
||||
"Meiryo",
|
||||
"Noto Sans CJK JP",
|
||||
"Noto Sans JP",
|
||||
"Source Han Sans JP",
|
||||
"Source Han Sans",
|
||||
"IPAexGothic",
|
||||
"IPAGothic",
|
||||
"TakaoGothic",
|
||||
"VL Gothic",
|
||||
];
|
||||
|
||||
const JAPANESE_FONT: &str = "system-japanese";
|
||||
|
||||
/// A Japanese font already installed on this system, for the interface (none is bundled). Looked up
|
||||
/// once per process: the system font folders are scanned on first use (name tables only), then the
|
||||
/// chosen face's file is read. `None` on the web and on systems without a Japanese font.
|
||||
pub fn system_japanese_font() -> Option<Arc<egui::FontData>> {
|
||||
static FONT: OnceLock<Option<Arc<egui::FontData>>> = OnceLock::new();
|
||||
FONT.get_or_init(|| {
|
||||
filmcraft_text::fonts::scan_system();
|
||||
let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect();
|
||||
let covers = |f: &filmcraft_text::fonts::Face| JAPANESE_SAMPLE.chars().all(|c| f.has_char(c));
|
||||
// within a family, the face closest to regular weight
|
||||
let by_weight = |f: &&Arc<filmcraft_text::fonts::Face>| f.info.weight.abs_diff(400);
|
||||
let preferred =
|
||||
PREFERRED_JAPANESE.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight));
|
||||
let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?;
|
||||
// kept for the life of the process (one font, read once) so installing it again after a
|
||||
// theme change shares the bytes instead of copying the whole file
|
||||
let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice());
|
||||
Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() }))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Add the system's Japanese font as the last fallback of every theme font family, from the next
|
||||
/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again
|
||||
/// after `theme::install`, which replaces the font definitions.
|
||||
pub fn install_japanese_font(ctx: &egui::Context) -> bool {
|
||||
let Some(font) = system_japanese_font() else { return false };
|
||||
let families = crate::theme::font_families()
|
||||
.into_iter()
|
||||
.map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest })
|
||||
.collect();
|
||||
// queued for the next pass (works before the first frame); a no-op when already installed
|
||||
ctx.add_font(egui::epaint::text::FontInsert { name: JAPANESE_FONT.into(), data: (*font).clone(), families });
|
||||
true
|
||||
}
|
||||
|
||||
const JAPANESE: &[(&str, &str)] = &[
|
||||
("Clip", "クリップ"),
|
||||
("Sequence", "シーケンス"),
|
||||
("Markers", "マーカー"),
|
||||
("Graphics and Titles", "グラフィックスとタイトル"),
|
||||
("Settings", "環境設定"),
|
||||
("Object", "オブジェクト"),
|
||||
("Effect", "効果"),
|
||||
("Settings…", "環境設定…"),
|
||||
("Image", "画像"),
|
||||
("Layer", "レイヤー"),
|
||||
("Type", "書式"),
|
||||
("Select", "選択"),
|
||||
("Filter", "フィルター"),
|
||||
("Window", "ウィンドウ"),
|
||||
("Language", "表示言語"),
|
||||
("Save As…", "別名で保存…"),
|
||||
("Exit", "終了"),
|
||||
("New…", "新規…"),
|
||||
("New", "新規"),
|
||||
("Horizontal", "横書き"),
|
||||
("Vertical", "縦書き"),
|
||||
("Orientation", "組み方向"),
|
||||
("Layers", "レイヤー"),
|
||||
("History", "履歴"),
|
||||
("Properties", "プロパティ"),
|
||||
("Color", "カラー"),
|
||||
("Brush Settings", "ブラシ設定"),
|
||||
("Tools", "ツール"),
|
||||
("Options", "オプション"),
|
||||
("Zoom In", "ズームイン"),
|
||||
("Zoom Out", "ズームアウト"),
|
||||
("Fit on Screen", "画面に合わせる"),
|
||||
("Copy", "コピー"),
|
||||
("Cut", "切り取り"),
|
||||
("Paste", "貼り付け"),
|
||||
("Select All", "すべて選択"),
|
||||
("Deselect", "選択を解除"),
|
||||
("Export", "書き出し"),
|
||||
("Export As…", "形式を指定して書き出し…"),
|
||||
("Search…", "検索…"),
|
||||
("Theme", "テーマ"),
|
||||
("Menu", "メニュー"),
|
||||
("File", "ファイル"),
|
||||
("Edit", "編集"),
|
||||
("Pages", "ページ"),
|
||||
("View", "表示"),
|
||||
("Help", "ヘルプ"),
|
||||
("Preferences", "環境設定"),
|
||||
("Preferences…", "環境設定…"),
|
||||
("Interface language", "表示言語"),
|
||||
("Open…", "開く…"),
|
||||
("New blank PDF", "空白の PDF を作成"),
|
||||
("Create PDF from file…", "ファイルから PDF を作成…"),
|
||||
("Create PDF from images…", "画像から PDF を作成…"),
|
||||
("Create PDF from clipboard", "クリップボードから PDF を作成"),
|
||||
("Combine files…", "ファイルを結合…"),
|
||||
("Save", "保存"),
|
||||
("Save as…", "別名で保存…"),
|
||||
("Close file", "ファイルを閉じる"),
|
||||
("Close all", "すべて閉じる"),
|
||||
("Revert", "保存済みの状態に戻す"),
|
||||
("Print…", "印刷…"),
|
||||
("Document properties…", "文書のプロパティ…"),
|
||||
("Undo", "取り消し"),
|
||||
("Redo", "やり直し"),
|
||||
("Find…", "検索…"),
|
||||
("Advanced search…", "高度な検索…"),
|
||||
("Copy pages", "ページをコピー"),
|
||||
("Cut pages", "ページを切り取り"),
|
||||
("Paste pages", "ページを貼り付け"),
|
||||
("Fit visible", "表示範囲に合わせる"),
|
||||
("Marquee zoom", "範囲指定ズーム"),
|
||||
("Take a snapshot", "スナップショットを作成"),
|
||||
("Full screen mode", "全画面表示"),
|
||||
("Read mode", "閲覧モード"),
|
||||
("Switch light / dark theme", "明るい/暗いテーマを切り替え"),
|
||||
("Comments panel", "コメントパネル"),
|
||||
("Form fields panel", "フォームフィールドパネル"),
|
||||
("Clear form", "フォームをクリア"),
|
||||
("Find tools and commands…", "ツールとコマンドを検索…"),
|
||||
("Zoom", "ズーム"),
|
||||
("Actual size", "実際のサイズ"),
|
||||
("Zoom to page level", "ページ全体を表示"),
|
||||
("Fit to width", "幅に合わせる"),
|
||||
("Display theme", "表示テーマ"),
|
||||
("Side panels", "サイドパネル"),
|
||||
("OK", "OK"),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn translations_are_unique_and_preserve_unknown_text() {
|
||||
for (i, (en, ja)) in JAPANESE.iter().enumerate() {
|
||||
assert!(!ja.is_empty());
|
||||
assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other));
|
||||
assert_eq!(Language::En.tr(en), *en);
|
||||
}
|
||||
assert_eq!(Language::Ja.tr("File"), "ファイル");
|
||||
assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf");
|
||||
assert_eq!(Language::parse("xx"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_commands_switch_and_persist_without_a_document() {
|
||||
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::Ja);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(restored.language, Language::Ja);
|
||||
crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_needs_an_installed_font() {
|
||||
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default()));
|
||||
let r = crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({}));
|
||||
if system_japanese_font().is_none() {
|
||||
// nothing is bundled: refused, and the interface stays English
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
assert_eq!(app.ui.language, Language::En);
|
||||
return;
|
||||
}
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
ctx.fonts_mut(|fonts| {
|
||||
// every theme family falls back to the system Japanese font
|
||||
for family in crate::theme::font_families() {
|
||||
let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default();
|
||||
assert_eq!(stack.last().map(String::as_str), Some(JAPANESE_FONT), "{family:?}: {stack:?}");
|
||||
}
|
||||
// and the glyphs resolve. (Only families whose replacement-box face is another font:
|
||||
// egui's `has_glyph` reports false for any character served by the face it also uses
|
||||
// for the replacement box, which in the Inter-only "medium"/"semibold" stacks is the
|
||||
// Japanese font itself.)
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
||||
let font = egui::FontId::new(13.0, family);
|
||||
for ch in JAPANESE_SAMPLE.chars() {
|
||||
assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod crash;
|
||||
pub mod dock;
|
||||
pub mod frames;
|
||||
pub mod header;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod links;
|
||||
pub mod menus;
|
||||
@@ -854,8 +855,8 @@ impl FilmcraftApp {
|
||||
if self.bindings_rev != self.session.shortcuts.revision {
|
||||
self.bindings = menus::bindings(self);
|
||||
self.bindings_rev = self.session.shortcuts.revision;
|
||||
let items = menus::menu_items(self);
|
||||
if let Some(hook) = self.hooks.shortcuts_changed.as_mut() {
|
||||
let items = menus::menu_items_for(&self.session);
|
||||
hook(&items);
|
||||
}
|
||||
}
|
||||
@@ -1235,6 +1236,11 @@ impl eframe::App for FilmcraftApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if !self.styled {
|
||||
theme::install(ctx, &self.tokens);
|
||||
// theme::install replaces the fonts: add the system Japanese font back (or fall back to
|
||||
// English when a saved Japanese setting meets a system without one)
|
||||
if self.ui.language == i18n::Language::Ja && !i18n::install_japanese_font(ctx) {
|
||||
self.ui.language = i18n::Language::En;
|
||||
}
|
||||
self.styled = true;
|
||||
ctx.request_repaint();
|
||||
} else {
|
||||
|
||||
@@ -22,6 +22,8 @@ macro_rules! uic {
|
||||
}
|
||||
|
||||
pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
uic!("app.language.english", "English", ["Edit", "Language"], None),
|
||||
uic!("app.language.japanese", "日本語", ["Edit", "Language"], None),
|
||||
uic!("playback.toggle", "Play/Stop", [], Some("Space")),
|
||||
uic!("playback.forward", "Shuttle Right", [], Some("L")),
|
||||
uic!("playback.stop", "Shuttle Stop", [], Some("K")),
|
||||
@@ -144,6 +146,19 @@ pub fn panel_command_id(p: PanelKind) -> String {
|
||||
|
||||
/// Execute a UI or engine command by id.
|
||||
pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
if matches!(id, "app.language.english" | "app.language.japanese") {
|
||||
// no Japanese font is bundled: Japanese needs one installed on the system
|
||||
if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) {
|
||||
return Err("no Japanese font is installed on this system (for example Noto Sans CJK JP); the interface stays in English".into());
|
||||
}
|
||||
app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En };
|
||||
let items = menu_items(app);
|
||||
if let Some(hook) = app.hooks.shortcuts_changed.as_mut() {
|
||||
hook(&items);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
return Ok(json!(app.ui.language));
|
||||
}
|
||||
if id == "perf.stats" {
|
||||
// the engine's counters plus playback, frame workers and UI timings
|
||||
return Ok(crate::perf::stats(app));
|
||||
@@ -382,6 +397,12 @@ pub const MENUS: [&str; 9] = ["File", "Edit", "Clip", "Sequence", "Markers", "Gr
|
||||
pub fn menu_items(app: &FilmcraftApp) -> Vec<MenuItem> {
|
||||
let mut v = menu_items_for(&app.session);
|
||||
for it in &mut v {
|
||||
it.label = app.ui.language.tr(&it.label).to_string();
|
||||
match it.id.as_str() {
|
||||
"app.language.english" => it.checked = Some(app.ui.language == crate::i18n::Language::En),
|
||||
"app.language.japanese" => it.checked = Some(app.ui.language == crate::i18n::Language::Ja),
|
||||
_ => {}
|
||||
}
|
||||
if it.id.starts_with("view.") {
|
||||
it.checked = crate::panels::monitor_view::checked(app, &it.id).or(crate::panels::menu_dialogs::checked(app, &it.id));
|
||||
it.enabled &= crate::panels::monitor_view::enabled(app, &it.id);
|
||||
@@ -518,13 +539,14 @@ pub fn external_commands() -> Vec<filmcraft_engine::shortcuts::CommandInfo> {
|
||||
|
||||
/// Draw the in-window menu bar.
|
||||
pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("interface-language"), app.ui.language));
|
||||
let items = menu_items(app);
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut clicked: Option<String> = None;
|
||||
egui::MenuBar::new().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(top, |ui| {
|
||||
ui.menu_button(app.ui.language.tr(top), |ui| {
|
||||
ui.set_min_width(260.0);
|
||||
if mine.is_empty() {
|
||||
ui.add_enabled(false, egui::Button::new("(empty)"));
|
||||
@@ -549,7 +571,8 @@ fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mu
|
||||
}
|
||||
subs.push(sub);
|
||||
let inner: Vec<&MenuItem> = items.iter().copied().filter(|x| x.path.get(depth).map(String::as_str) == Some(sub)).collect();
|
||||
ui.menu_button(sub, |ui| {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1679,7 +1679,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
.iter()
|
||||
.filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": shift_track(seq, tid, track_delta).unwrap_or(tid).0, "time": (it.start + offset).max(Tick::ZERO).0})))
|
||||
.collect();
|
||||
Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command})))
|
||||
Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false})))
|
||||
}
|
||||
Drag::Trim { clip, edge, mode, delta } if delta != Tick::ZERO => Some(app.session.execute(
|
||||
"timeline.trim",
|
||||
|
||||
@@ -344,6 +344,8 @@ pub enum GuideDialog {
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UiState {
|
||||
#[serde(default)]
|
||||
pub language: crate::i18n::Language,
|
||||
pub tool: Tool,
|
||||
pub mode: Mode,
|
||||
pub workspace: String,
|
||||
@@ -721,6 +723,7 @@ fn yes() -> bool {
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
language: crate::i18n::Language::default(),
|
||||
tool: Tool::Selection,
|
||||
mode: Mode::Edit,
|
||||
workspace: "Editing".into(),
|
||||
|
||||
@@ -231,6 +231,12 @@ impl Tokens {
|
||||
}
|
||||
|
||||
/// Install fonts (Inter, Inter SemiBold, JetBrains Mono) and egui visuals.
|
||||
/// Every font family the theme defines (fallback fonts such as the system Japanese font are added to
|
||||
/// each of them).
|
||||
pub fn font_families() -> Vec<FontFamily> {
|
||||
vec![FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("semibold".into()), FontFamily::Name("medium".into())]
|
||||
}
|
||||
|
||||
pub fn install(ctx: &egui::Context, t: &Tokens) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));
|
||||
|
||||
+60
-1
@@ -39,8 +39,11 @@ claude mcp add filmcraft-headless -- /abs/path/filmcraft/target/release/filmcraf
|
||||
|---|---|---|
|
||||
| `command_list` | both | every command: id, label, menu, shortcut, params, enabled now (`filter`, `enabled_only`) |
|
||||
| `command_run` | both | run a command `{id, params}`; edits are undoable |
|
||||
| `command_batch` | both | run several commands in order `{steps: [{id, params}], stop_on_error}` → `{completed, failed, results}` |
|
||||
| `doc_inspect` | both | the project tree and the active sequence in one call (`project_inspect` + `sequence_inspect`) |
|
||||
| `render_preview` | both | same as `render_frame` |
|
||||
| `project_inspect` | both | bins and items with ids, types, durations; active sequence |
|
||||
| `sequence_inspect` | both | the active sequence: tracks, clips (ticks and frames), effects, transitions, markers, playhead, selection |
|
||||
| `sequence_inspect` | both | the active sequence: tracks, clips (`start` / `end` and `sourceIn` / `sourceOut` in ticks, frames, `speed`, `reverse`: the clip plays its `sourceIn` to `sourceOut` stretch backward, `gainDb`), effects, transitions, markers (name and `comment`), playhead, selection |
|
||||
| `media_import` | both | import files by absolute path (`text`, one path per line) |
|
||||
| `render_frame` | both | PNG of the program frame at `seconds` (headless renders; bridge screenshots the Program monitor) |
|
||||
| `ui_inspect` | bridge | UI state: tool, workspace, panels, zoom, playback, fps |
|
||||
@@ -55,6 +58,47 @@ claude mcp add filmcraft-headless -- /abs/path/filmcraft/target/release/filmcraf
|
||||
Typical loop: `project_inspect` / `sequence_inspect` → get ids → `command_run` → `render_frame` or
|
||||
`ui_screenshot` → look at the result → `edit.undo` if it's wrong.
|
||||
|
||||
### Conventions
|
||||
|
||||
The server follows a few conventions that make it predictable for agents. They are not
|
||||
FilmCraft-specific: the sibling craft apps' MCP servers can follow the same ones, so an agent that has
|
||||
driven one app can drive the others.
|
||||
|
||||
- **Core tools.** `command_list`, `command_run`, `command_batch`, `doc_inspect` and `render_preview`
|
||||
work in both modes. The FilmCraft tools above stay as they are.
|
||||
- **Titles and annotations.** Every tool has a `title` and the four MCP hints (`readOnlyHint`,
|
||||
`destructiveHint`, `idempotentHint`, `openWorldHint`), so a client can let the read-only tools
|
||||
(lists, inspects, renders, screenshots) run without asking and ask before edits. When you add a
|
||||
tool, set them in its `#[tool(...)]` attribute; a test checks every listed tool.
|
||||
- **Strict arguments.** An unknown tool argument is a JSON-RPC `-32602` error that names it and the
|
||||
accepted ones (`unknown argument "filtr" for command_list; expected: enabled_only, filter`), not a
|
||||
silently ignored key. `command_run.params` goes to the command unchanged.
|
||||
- **Errors.** A failing command is an `isError` result with the engine's message. A line that is not
|
||||
JSON gets a `-32700` parse error (id `null`) and the server keeps serving. A command that panics is
|
||||
reported as `internal error: …`, and the session stays usable.
|
||||
- **Resources.** `filmcraft://document` (the project tree and active sequence, as `doc_inspect`) and
|
||||
`filmcraft://commands` (the command catalog, as `command_list`), both `application/json`. For MCP
|
||||
2026-07-28 clients such as current Claude Code, list and read results carry the `ttlMs` and
|
||||
`cacheScope` hints that revision requires.
|
||||
|
||||
### Long exports: progress and cancellation
|
||||
|
||||
`command_run {"id": "file.exportMedia", "params": {…, "wait": true}}` in headless mode blocks until
|
||||
the file is written. It uses the MCP progress and cancellation utilities, so a client can show
|
||||
progress and stop it:
|
||||
|
||||
- The export runs as the same background job `jobs.list` shows. The session is locked only for short
|
||||
polls, so other requests (`ping`, `doc_inspect`, `jobs.list`, …) are answered while it encodes.
|
||||
- With `"_meta": {"progressToken": T}` on the `tools/call`, the server sends
|
||||
`notifications/progress` `{progressToken: T, progress: <frames done>, total: <frames>, message:
|
||||
<job status>}` at most every 100 ms while frames advance. Without a token it sends none.
|
||||
- `notifications/cancelled` for the request stops the encode at the next batch (`jobs.cancel`),
|
||||
deletes the partial output (the movie, or the frames and caption sidecar written so far) and sends
|
||||
no response, as the MCP cancellation utility asks.
|
||||
- Without `wait` the command returns `{job, path}` at once, as before: poll `jobs.list` and stop it
|
||||
with `jobs.cancel`. Other exports (`file.exportFrame`, interchange formats, …) finish quickly and
|
||||
ignore the token. Bridge mode forwards the call unchanged; the app shows its own progress.
|
||||
|
||||
## 2. Control channel
|
||||
|
||||
`filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT=9876`) listens on `127.0.0.1` only. Send one
|
||||
@@ -89,10 +133,25 @@ Notes:
|
||||
|
||||
- Time is in ticks (254 016 000 000 per second). Commands also accept `seconds`, `frame` or
|
||||
`timecode`.
|
||||
- A track parameter (`track`, `audioTrack`) is a track id or a name such as `"V1"` / `"A2"`.
|
||||
Leaving it out picks the command's default track; naming a track the sequence does not have is
|
||||
an "invalid parameters" error, never a different track.
|
||||
In `timeline.place`, `track` is where the picture goes and `audioTrack` where the sound goes;
|
||||
an audio track given as `track` places the sound alone, on that track.
|
||||
- `engine.execute` also runs UI-only commands (`tool.razor`, `playback.toggle`,
|
||||
`window.workspace.color`, `window.panel.<name>`).
|
||||
- A command that works on the selection is disabled ("no clips selected", "select a clip in the
|
||||
Project panel") until something is selected, unless the call names its targets under a key the
|
||||
command documents (`clips` / `clip` for timeline clips, `items` / `item` for project items):
|
||||
`clip.replaceFromBin {"clips":[12],"item":4}` runs with nothing selected and leaves the
|
||||
selection alone. Only the selection condition is answered by the named targets; every other
|
||||
one (an open sequence, a filled clipboard, the right kind of clip) still applies, and ids of
|
||||
nothing do not count. `command_list` / `engine.commands` keep reporting the selection's state.
|
||||
- Element ids come from the previous frame. If an element is missing right after a layout change,
|
||||
the app retries on later frames before giving up.
|
||||
- `timeline.move` takes linked partners along while Linked Selection is on: moving a picture
|
||||
clip moves its sound by the same offset (pass `"linked": false` to move only the listed clips).
|
||||
It returns `{"moved": [ids]}`, every clip that moved.
|
||||
- Without a window, `filmcraft-cli run script.jsonl` (lines of `{"id":"…","params":{…}}`) runs the
|
||||
same commands headlessly. See §2b for the rest of the CLI.
|
||||
|
||||
|
||||
@@ -601,6 +601,11 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
|
||||
| `png`, `tiff`, `bmp` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
|
||||
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) |
|
||||
|
||||
- **Reproducible.** The same project and settings give the same file on every machine. Frames render
|
||||
in batches of one per core, but the muxer gets them in fixed groups of 16 output frames
|
||||
(`INTERLEAVE` in `crates/export/src/job.rs`), each followed by its audio, and H.264 uses one slice
|
||||
per four macroblock rows instead of one per core. MXF files are the exception: their UMIDs and
|
||||
modification date come from the clock, as SMPTE ST 330 / ST 377 expect.
|
||||
- **Settings.** `ExportSettings` (serde, camelCase, every field optional) holds Video (frame size or
|
||||
Match Source, frame rate, Scale to Fit / Fill / Stretch, pixel aspect, field order — progressive
|
||||
only, profile / level, bitrate encoding, target / maximum / adaptive bitrate, keyframe distance,
|
||||
@@ -624,7 +629,9 @@ Video encoders implement `export::VideoEncoder`. Codec crates plug in with `regi
|
||||
Timelines can be exchanged as EDL, FCP7 XML, FCPXML, OTIO, AAF or OMF. `file.import` detects these
|
||||
formats and merges the result into the project as one undoable step (AAF / OMF embedded audio is
|
||||
written next to the document first), and `file.exportInterchange`, `file.exportEdl`,
|
||||
`file.exportFcpxml`, `file.exportOtio`, `file.exportAaf` and `file.exportOmf` write them. For AAF and
|
||||
`file.exportFcpxml`, `file.exportOtio`, `file.exportAaf` and `file.exportOmf` write them.
|
||||
`file.exportInterchange {format}` takes `edl`, `xml` (the default), `fcpxml`, `otio`, `aaf` or `omf`;
|
||||
any other value is a parameter error and nothing is written. For AAF and
|
||||
OMF the engine (`engine::aaf_omf`) first prepares the audio the document references: it lists the
|
||||
used ranges (`interchange::essence::audio_needs`), decodes or renders them (clip effects through the
|
||||
export audio pipeline), embeds them or writes WAV / AIFF files, and optionally renders a video
|
||||
|
||||
@@ -38,6 +38,9 @@ cargo xtask web --serve 8765 # the web app on http://12
|
||||
|
||||
`FILMCRAFT_CONTROL_PORT=9876` works like `--control 9876`, and `FILMCRAFT_CPU_COMPOSITE=1`
|
||||
disables the GPU compositor.
|
||||
`filmcraft --help` lists the app's options. An option it does not know, or a control port that is
|
||||
not a number (from `--control` or from `FILMCRAFT_CONTROL_PORT`), is an error on stderr with exit
|
||||
code 2 instead of a window.
|
||||
|
||||
Dev builds compile dependencies at `opt-level = 2` and workspace crates at `opt-level = 1`. For
|
||||
playback and codec speed, use `--release`.
|
||||
|
||||
@@ -29,7 +29,13 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
|
||||
| `ui.playback` | `{action: play|stop|toggle, speed?}` | |
|
||||
| `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) |
|
||||
| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus |
|
||||
| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) |
|
||||
| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle plane buffers of evicted frames, and planes decoded into a recycled one), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) |
|
||||
|
||||
**Selection or explicit targets.** A command that acts on the selection is "not available right now" when
|
||||
nothing is selected. When `params` name the targets under a key the command documents (`clips` /
|
||||
`clip`: timeline clips of the active sequence, `items` / `item`: project items), enablement is
|
||||
checked against those instead and the selection is left as it is; the command's other conditions
|
||||
still apply. `engine.commands` and the menus always show the selection's enablement.
|
||||
|
||||
**Focus.** Driving the app never steals the user's keyboard. Started with `--control`, the app opens
|
||||
without activating itself. UI requests and screenshots that need a rendered frame bring the window
|
||||
@@ -46,7 +52,7 @@ solo, record arm, solo safe, mode Off/Read/Latch/Touch/Write, output, input map,
|
||||
`mixer.recordStart` / `mixer.recordStop` (playback runs them), `mixer.addSubmix`, `mixer.deleteSubmix`,
|
||||
`mixer.addInsert` / `removeInsert` / `setInsert`, `mixer.addSend` / `setSend` / `removeSend`,
|
||||
`mixer.setKeyframe` / `deleteKeyframe` / `moveKeyframe` / `clearLane`, `mixer.writeAutomation`;
|
||||
`clipMixer.set`; `clip.audioGain {mode: set|adjust|normalizeMax|normalizeAll, db}`, `clip.audioPeak`;
|
||||
`clipMixer.set`; `clip.audioGain {clips?, mode: set|adjust|normalizeMax|normalizeAll, db}`, `clip.audioPeak`;
|
||||
`effects.setDefaultTransition`. UI ids: `mixer.<A1|S1|Mix>.<fader|value|pan|panValue|mode|mute|solo|
|
||||
record|soloSafe|output|input|fx.<n>|send.<n>|meter|name>` (popup entries below them, e.g.
|
||||
`mixer.A1.mode.Touch`, `mixer.A1.fx.0.studio_reverb`), `mixer.showEffects`, `mixer.transport.<cmd>`,
|
||||
@@ -74,12 +80,33 @@ management commands and dialog ids: offline media and relinking (`media.findMiss
|
||||
proxies (`media.createProxies`, `media.attachProxies`, `media.toggleProxies`, `proxies.*`), ingest
|
||||
(`project.ingestSettings`) and the Project Manager (`file.projectManager`, `pm.*`).
|
||||
|
||||
**Moving clips**: `timeline.move {moves: [{clip, track, time}], insert?, linked?}` → `{moved: [clip ids]}`.
|
||||
With Linked Selection on (the default; `linked` overrides the toggle for one call) the linked
|
||||
partners of a listed clip that are not listed themselves move by the same time offset and stay on
|
||||
their own tracks, so picture and sound stay in sync. Like a listed clip, a partner overwrites
|
||||
what is at its destination (or inserts, with `insert`). A partner on a locked track stays where it
|
||||
is, and so does one whose position would not change (the listed clip only changed track). `moved`
|
||||
lists every clip that moved, partners included. No clip is moved before the sequence
|
||||
start: if one would be, all clips of the call land later by the same amount (their spacing is
|
||||
kept). A `time` beyond the representable range is an "invalid parameters" error. The timeline
|
||||
panel's drag passes `linked: false`, because it already lists exactly the clips it moves.
|
||||
|
||||
**Trim mode** (`crates/engine/src/trim.rs`): `trim.selectEditPoint` / `trim.selectNearest` enter trim
|
||||
mode (the Program monitor becomes the Trim Monitor, ids `trimMonitor.*`); `trim.monitor` returns what
|
||||
it shows. Dynamic trimming takes an explicit clock (seconds) so it is deterministic: `trim.shuttle
|
||||
{direction, slow?, clock}` (J/L), `trim.tick {clock}`, `trim.shuttleStop {clock?}` (K, one undo step),
|
||||
`trim.cancelDynamic` (Esc), `trim.playAround {clock, loop?}` (Space / Shift+K).
|
||||
|
||||
**Replace With Clip**: `clip.replaceFromSource`, `clip.replaceFromSourceMatchFrame` and
|
||||
`clip.replaceFromBin {clips?, item?, keepSourceIn?}` swap the media of the clips and return
|
||||
`{clips: [ids], item, short: [{clip, shortByFrames}]}`. `short` lists the replaced clips that now
|
||||
run past the end of their media (the renderer holds the last frame there) with the number of
|
||||
sequence frames that have no media, at most the clip's length; it is empty when the new media
|
||||
covers every clip. The edit is the same either way. From Bin starts each clip at the new item's In point; with
|
||||
`keepSourceIn: true` each clip keeps its own source In instead. A subclip that restricts trims has
|
||||
no media outside its range, so there a kept In is moved to the subclip's first or last frame, and
|
||||
the result's `sourceInClamped: [ids]` (present with `keepSourceIn`) names the clips it was moved for.
|
||||
|
||||
**Colour** (`crates/engine/src/color.rs`): `sequence.colorSettings {workingSpace: rec709|rec2100-pq|
|
||||
rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace: auto|<id>}`,
|
||||
`color.spaces`, `media.colorInfo {item}`; LUTs: `lut.import {path, name?}`, `lut.list`,
|
||||
@@ -103,7 +130,10 @@ Ctrl), `Ctrl` (macOS ⌃), `Alt`, `Shift`. The dialog (Edit ▸ Keyboard Shortcu
|
||||
## MCP
|
||||
`filmcraft-cli mcp` serves MCP on stdio: headless (in-process session; `--demo` / `--project p.fcproj`)
|
||||
or `--bridge 127.0.0.1:9876` to drive the running app. Tools: `command_list`, `command_run`,
|
||||
`project_inspect`, `sequence_inspect`, `media_import`, `render_frame`, `ui_inspect`, `ui_elements`,
|
||||
`ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_screenshot`, `ui_control`. `.mcp.json` registers both.
|
||||
`command_batch`, `doc_inspect`, `render_preview`, `project_inspect`, `sequence_inspect`, `media_import`,
|
||||
`render_frame`, `ui_inspect`, `ui_elements`, `ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_screenshot`,
|
||||
`ui_control`; resources `filmcraft://document` and `filmcraft://commands`. `.mcp.json` registers both.
|
||||
Annotations, argument checking, errors and export progress / cancellation:
|
||||
[agents.md § Conventions](agents.md#conventions).
|
||||
|
||||
Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, `frame` or `timecode`.
|
||||
@@ -13,6 +13,52 @@ the before/after comparison relies on. Each milestone below alternated base / af
|
||||
each); the most load-independent figure is the decoder's own cycle count (`proc_pid_rusage`
|
||||
instructions and cycles of a process, all threads summed).
|
||||
|
||||
## Memory of clips on a timeline (MEM1–MEM3)
|
||||
|
||||
Measured on the desktop release build with `footprint`, `heap` and `malloc_history`
|
||||
(`MallocStackLogging=lite`), 2026-10-05: twelve H.264 clips dropped on a timeline took the process
|
||||
to a 12 GB footprint, 10.8 GB of it live heap in three piles.
|
||||
|
||||
1. **Media files read whole (2.9 GB), MEM1.** The desktop host had no `Services::reader`, so
|
||||
`MediaPool::open_file` fell back to `read_file` and every clip's bytes stayed in memory for as
|
||||
long as its source was open. `FsServices::reader` now returns a
|
||||
`filmcraft_media::reader::FileReader`: the file stays open and containers read their index and
|
||||
then single samples with positional reads, as the web host already did through `BlobReader`
|
||||
([web.md](web.md)). Formats decoded in one go (stills, WAV, MPEG elementary streams) are still
|
||||
read whole through the reader.
|
||||
2. **A decoder per clip, for good (3.5 GB), MEM2.** Every source kept its decoder once it had
|
||||
decoded a frame, with its reference pictures and the pictures its frame threads have in flight:
|
||||
about 290 MB per clip here (`h264` `PicState`, `Frame::make_row`). The GOP caches now share a
|
||||
pool (`crates/codecs/src/gop.rs`): beyond `MAX_LIVE_DECODERS` (4) the least recently used
|
||||
caches that have been idle for 3 s drop their decoder and make a new one when asked again,
|
||||
which costs what a seek costs. A cache in use (a layer of the frame being composited, the next
|
||||
clip being prefetched) always keeps its decoder, so a composite of more than four clips does
|
||||
not restart decoders on every frame. `perf.stats` reports `decode.liveDecoders`.
|
||||
3. **A frame budget per clip, none overall (3.8 GB), MEM3.** Each GOP cache keeps up to 384 MB
|
||||
of decoded frames (at least 64 frames), which every clip on the timeline filled and kept. The
|
||||
pool now counts the frames of all caches against `FRAME_BUDGET` (1 GiB): over it, caches idle
|
||||
for 3 s give up frames, least recently used cache first, earliest frames first. Caches in use
|
||||
keep their own budget, because a frame evicted before it is shown costs a re-decode from the
|
||||
keyframe. `perf.stats` reports `decode.cacheMB` and `decode.cacheBudgetMB`; before, this
|
||||
memory appeared in no counter (`frames.cache` covers the frame server's rendered images and
|
||||
GPU plans only).
|
||||
|
||||
4. **Freed planes the allocator keeps (2.5 GB), MEM4.** With MEM1–MEM3 the same twelve clips took
|
||||
5.1 GB, only 2.5 GB of it live: every decoded picture allocated three planes and every
|
||||
eviction freed them, and the system allocator kept the freed pages (`footprint`: 1.7 GB
|
||||
reclaimable). `filmcraft_frame::pool` now keeps the planes of evicted frames (up to 192 MiB per
|
||||
sample type; a frame something else still holds is left alone) and the H.264 decoder and the
|
||||
VideoToolbox path decode into them, so steady-state decoding allocates no planes.
|
||||
`perf.stats` reports `decode.planePoolMB` and `decode.planesReused`.
|
||||
|
||||
The same session showed that the desktop app never used hardware decoding: `register()` was an
|
||||
argument of a `log::info!` that no logger evaluated (fixed under HW1; `decode.hardware.sessions`
|
||||
was 0 with every frame decoded in software).
|
||||
|
||||
What remains per live decoder is unchanged: the H.264 decoder publishes each macroblock row of a
|
||||
picture as its own allocations (`Frame::make_row`, five per row), which is where the 290 MB per
|
||||
decoder comes from.
|
||||
|
||||
## Results (GPU2: standard effects on the GPU compositor, #30, before → after)
|
||||
|
||||
Before = this change with clips that carry standard effects sent back to the CPU layer path in
|
||||
|
||||
@@ -19,6 +19,13 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm
|
||||
- `project` is the serialized `filmcraft_project::Project` (bins, media clips, sequences, tracks,
|
||||
clips, effects, keyframes, markers). Media is referenced by path, never embedded.
|
||||
- Files are written compact (no indentation). A 2,000-clip project is about 1.4 MB.
|
||||
- A coordinate of an effect's point parameter may be `null`. Point parameters use NaN for "auto"
|
||||
(the frame centre, or the source centre for `anchor`), and JSON writes NaN as `null`; it reads back
|
||||
as "auto". Placing a clip resolves these points at once. An interchange import resolves them
|
||||
too, except the `anchor` of a clip whose media file was not found. That one stays "auto", and
|
||||
the renderer centres it, until Link Media (`media.relink`, `media.autoRelink`) reads the file
|
||||
and with it the real picture size; a file that merely comes back at its old path does not
|
||||
change it. `null` anywhere else is a damaged file.
|
||||
|
||||
### Schema history
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2).
|
||||
| MPEG TS / PS (via `codecs/tests/mpeg_oracle.rs`) | ffmpeg-written TS (MPEG-2 + MP2, H.264 + AAC, HEVC + LATM AAC), BDAV `.m2ts` / `.mts` (H.264 + Blu-ray LPCM / AC-3), VOB (MPEG-2 + MP2 / DVD LPCM 16 and 24-bit / AC-3), MPEG-1 system streams, `.m2v`; MPEG-2 in MOV (XDCAM `xd5c`), MP4 (`mp4v`), MKV | frame count and every frame's **PTS exact** (ffprobe); H.264 / HEVC **bit-exact**, MPEG-2 within the `mpeg2v` criterion; random seeks = sequential decode; LPCM **sample-exact**; AAC ≤ 2.1·10⁻⁷, MP2 (symphonia) ≤ 3.2·10⁻⁵, AC-3 ≤ 5·10⁻³ (decoder-specific dither; measured 2.3·10⁻³); random-access audio = sequential |
|
||||
| `ac3` | ffmpeg AC-3 encodes (2/0 with coupling and rematrixing, 1/0 32 kHz, 3/2+LFE 448 kb/s, noise) decoded by ffmpeg | SNR per channel ≥ 40-60 dB on tonal content (measured 66-94 dB), ≥ 18 dB on noise (27.7 dB, the same as between two of our own dither seeds) |
|
||||
| `ogg` (via `codecs/tests/ogg_oracle.rs`) | ffmpeg (libopus, Vorbis) files decoded by libopus / ffmpeg | length **exact** (pre-skip, end trimming); SNR ≥ 50 dB CELT (measured 77.7), ≥ 10 dB SILK, Vorbis 139 dB; random seeks within 6·10⁻⁶ of the continuous decode |
|
||||
| MP4 audio at another rate (`codecs/tests/mp4_oracle.rs`) | ffmpeg-written AAC in MP4 (48 kHz, edit list skipping the priming) | reads resampled to 44.1 kHz in frame-sized requests equal the native decode interpolated at the same times (difference < 10⁻⁴, peak ≤ the source level) |
|
||||
| image sequences / BWF (`codecs/tests/media_oracle.rs`) | ffmpeg-written PNG/TIFF/BMP/JPEG stills read by ffmpeg's image2 demuxer; ffmpeg BWF (`-write_bext`) | PNG/TIFF/BMP **exact**, JPEG ±12 (measured 3); missing frames hold the previous one; BWF TimeReference = ffprobe's, start timecode, samples exact |
|
||||
| `export` | our own demuxer/decoder reads the file back; ffprobe counts frames when present | expected size, duration, colour, audio level; exact frame count |
|
||||
| `export` settings and presets (M6.5) | `export/src/settings_tests.rs`, engine `export_tests.rs`: every built-in preset exports a slice of the demo sequence; ffprobe reads codec, profile, size, rate, audio format and data rate; our decoders read pixels back | codec / profile / container / size / `r_frame_rate` / sample format exact; H.264 bitrate ≤ VBR max × 1.1 and ≥ ½ target over 3 s; ProRes ≤ 1.5 × nominal; DNxHR within 25 % of nominal; loudness normalization within **0.5 LU** of the target (WAV and AAC), true peak ≤ ceiling + 0.1 dB; image sequences numbered `<name>000…`; limiter, overlays, metadata (`title`/`copyright` tags), MOV multiplexer, two-pass/CBR progress |
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
Scope="perMachine"
|
||||
Compressed="yes"
|
||||
Language="1033">
|
||||
<SummaryInformation Description="FilmCraft image editor" Manufacturer="Learning Machines LLC" />
|
||||
<SummaryInformation Description="FilmCraft video editor" Manufacturer="Learning Machines LLC" />
|
||||
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other
|
||||
(MSI versions are numeric, so 1.2.3-rc.1 and 1.2.3-rc.2 are both 1.2.3). -->
|
||||
<MajorUpgrade AllowSameVersionUpgrades="yes"
|
||||
DowngradeErrorMessage="A newer version of FilmCraft is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
||||
|
||||
<Icon Id="FilmcraftIcon" SourceFile="$(var.IconPath)" />
|
||||
<Property Id="ARPPRODUCTICON" Value="FilmcraftIcon" />
|
||||
<Icon Id="FilmcraftIcon.ico" SourceFile="$(var.IconPath)" />
|
||||
<Property Id="ARPPRODUCTICON" Value="FilmcraftIcon.ico" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps/filmcraft" />
|
||||
<Property Id="ARPURLUPDATEINFO" Value="https://getartcraft.com/apps/filmcraft" />
|
||||
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
|
||||
@@ -45,8 +45,8 @@
|
||||
<Component Id="FilmcraftApp" Guid="15933017-925B-4963-8992-52000EB26AAA">
|
||||
<File Id="FilmcraftExe" Source="$(var.BinDir)\filmcraft.exe" KeyPath="yes">
|
||||
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="FilmCraft"
|
||||
Description="FilmCraft image editor" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
|
||||
Icon="FilmcraftIcon" IconIndex="0" />
|
||||
Description="FilmCraft video editor" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
|
||||
Icon="FilmcraftIcon.ico" IconIndex="0" />
|
||||
</File>
|
||||
|
||||
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\filmcraft.exe" Value="[#FilmcraftExe]" Type="string" />
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
.EXAMPLE
|
||||
pwsh packaging/windows/package.ps1 -Arch x64
|
||||
pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild
|
||||
pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools
|
||||
#>
|
||||
param(
|
||||
[ValidateSet('x64', 'x86')] [string] $Arch = 'x64',
|
||||
[ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64',
|
||||
[switch] $SkipBuild
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -44,7 +45,7 @@ if (-not $Version) { throw 'could not read [workspace.package] version from Carg
|
||||
# MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there.
|
||||
$MsiVersion = ($Version -split '-')[0]
|
||||
|
||||
$Target = if ($Arch -eq 'x64') { 'x86_64-pc-windows-msvc' } else { 'i686-pc-windows-msvc' }
|
||||
$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } }
|
||||
$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' }
|
||||
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' }
|
||||
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
|
||||
@@ -65,6 +66,23 @@ if (-not $SkipBuild) {
|
||||
}
|
||||
|
||||
$Bin = Join-Path $TargetDir "$Target\release"
|
||||
|
||||
# Check both binaries' PE headers before packaging. Machine (COFF header) must match -Arch, so an
|
||||
# x64 build can never ship labelled arm64. Subsystem (optional header): 2 = Windows GUI, 3 = console.
|
||||
# The app must be GUI (no console window opens with it); the CLI must stay console so its output
|
||||
# reaches the terminal.
|
||||
function Get-PeHeader([string] $Path) {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
|
||||
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
|
||||
}
|
||||
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
|
||||
foreach ($check in @(@('filmcraft.exe', 2), @('filmcraft-cli.exe', 3))) {
|
||||
$h = Get-PeHeader (Join-Path $Bin $check[0])
|
||||
if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }
|
||||
if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" }
|
||||
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
|
||||
}
|
||||
$Stage = Join-Path $TargetDir "windows-package\$Arch"
|
||||
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
|
||||
@@ -96,5 +114,12 @@ $Zip = Join-Path $Dist "filmcraft-$Version-windows-$Arch-portable.zip"
|
||||
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $Portable -DestinationPath $Zip
|
||||
|
||||
Invoke-Native 'filmcraft-cli --version' { & (Join-Path $Stage 'filmcraft-cli.exe') --version }
|
||||
# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run
|
||||
# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead.
|
||||
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
|
||||
if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') {
|
||||
Invoke-Native 'filmcraft-cli --version' { & (Join-Path $Stage 'filmcraft-cli.exe') --version }
|
||||
} else {
|
||||
Write-Output "skipping filmcraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
|
||||
}
|
||||
Get-Item $Msi, $Zip | Format-Table Name, Length
|
||||
Reference in new issue
Block a user