Merge remote-tracking branch 'upstream/main' into HEAD

This commit is contained in:
vangel76 2026-10-11 03:00:38 +02:00
commit b74202696d
40 changed files with 2143 additions and 196 deletions

View File

@ -4,9 +4,9 @@
# The jobs that sign (macOS, Windows) and the one that publishes the draft run in the `release`
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
# jobs that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no
# secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are refused,
# so the draft-release job (which needs them) is skipped and nothing is published.
# jobs that sign nothing (version, Linux incl. riscv64, Flatpak, FreeBSD, web) get no environment
# and no secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are
# refused, so the draft-release job (which needs them) is skipped and nothing is published.
name: Release
on:
@ -231,6 +231,80 @@ jobs:
path: dist/release/*
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU. Audio links ALSA (cpal), hence libasound2-dev:riscv64; the
# hardware video paths (VA-API, NVDEC/NVENC) are loaded at run time with libloading, so they
# need no riscv64 libraries and fall back to the pure-Rust codecs when absent.
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$FILMCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- name: Check the tarball
run: |
tar -tzf dist/release/filmcraft-*-linux-riscv64.tar.gz \
| grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
@ -365,7 +439,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, freebsd, web]
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:

98
Cargo.lock generated
View File

@ -1665,7 +1665,7 @@ dependencies = [
[[package]]
name = "filmcraft"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"arboard",
"chrono",
@ -1697,7 +1697,7 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@ -1707,7 +1707,7 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@ -1715,7 +1715,7 @@ dependencies = [
[[package]]
name = "filmcraft-apv"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@ -1725,14 +1725,14 @@ dependencies = [
[[package]]
name = "filmcraft-audio-dsp"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"filmcraft-engine",
@ -1748,7 +1748,7 @@ dependencies = [
[[package]]
name = "filmcraft-av1"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@ -1759,14 +1759,14 @@ dependencies = [
[[package]]
name = "filmcraft-bitstream"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"proptest",
]
[[package]]
name = "filmcraft-captions"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@ -1779,11 +1779,11 @@ dependencies = [
[[package]]
name = "filmcraft-cfb"
version = "0.5.0"
version = "0.6.0"
[[package]]
name = "filmcraft-cli"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-automation",
"filmcraft-codecs",
@ -1804,7 +1804,7 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
@ -1839,7 +1839,7 @@ dependencies = [
[[package]]
name = "filmcraft-color"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"proptest",
"serde",
@ -1847,7 +1847,7 @@ dependencies = [
[[package]]
name = "filmcraft-dnx"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@ -1857,7 +1857,7 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-geom",
"filmcraft-media",
@ -1870,7 +1870,7 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@ -1902,7 +1902,7 @@ dependencies = [
[[package]]
name = "filmcraft-export"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-aac",
"filmcraft-apv",
@ -1932,7 +1932,7 @@ dependencies = [
[[package]]
name = "filmcraft-format"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-media",
"filmcraft-project",
@ -1945,7 +1945,7 @@ dependencies = [
[[package]]
name = "filmcraft-frame"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@ -1955,7 +1955,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"proptest",
"serde",
@ -1963,7 +1963,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@ -1980,7 +1980,7 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-color",
"filmcraft-export",
@ -1999,7 +1999,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@ -2009,7 +2009,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@ -2019,7 +2019,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@ -2029,7 +2029,7 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@ -2046,7 +2046,7 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@ -2057,7 +2057,7 @@ dependencies = [
[[package]]
name = "filmcraft-matroska"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
@ -2065,7 +2065,7 @@ dependencies = [
[[package]]
name = "filmcraft-media"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@ -2087,7 +2087,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpeg2v"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-testkit",
"rayon",
@ -2096,7 +2096,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@ -2104,28 +2104,28 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-ogg"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-opus"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-platform"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-codecs",
@ -2151,7 +2151,7 @@ dependencies = [
[[package]]
name = "filmcraft-project"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@ -2165,7 +2165,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@ -2175,7 +2175,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@ -2195,7 +2195,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-color",
"serde",
@ -2204,7 +2204,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"candle-core",
"candle-nn",
@ -2222,14 +2222,14 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"png",
]
[[package]]
name = "filmcraft-text"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-color",
"harfrust",
@ -2241,7 +2241,7 @@ dependencies = [
[[package]]
name = "filmcraft-time"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"proptest",
"serde",
@ -2249,7 +2249,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"candle-core",
"candle-nn",
@ -2263,7 +2263,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts-text"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"serde",
"thiserror 2.0.21",
@ -2271,7 +2271,7 @@ dependencies = [
[[package]]
name = "filmcraft-ui-egui"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"cpu-time",
"eframe",
@ -2308,7 +2308,7 @@ dependencies = [
[[package]]
name = "filmcraft-vp9"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-bitstream",
"rayon",
@ -2317,7 +2317,7 @@ dependencies = [
[[package]]
name = "filmcraft-web"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"eframe",
"egui",
@ -7342,7 +7342,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"filmcraft-testkit",
"serde_json",

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.6.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"

View File

@ -362,6 +362,9 @@ and web backend defaults are unchanged.
| Fedora/RHEL/openSUSE | `filmcraft-<ver>-linux-x86_64.rpm` | `filmcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `filmcraft-<ver>-linux-x86_64.tar.gz` | `filmcraft-<ver>-linux-aarch64.tar.gz` | Unpack and run `./install.sh` |
RISC-V (riscv64): `filmcraft-<ver>-linux-riscv64.tar.gz` (tarball only; needs glibc 2.39+, e.g. Ubuntu 24.04
or Debian 13).
The tarball installer places FilmCraft, its command-line tool and desktop integration in
`~/.local`, without administrator permissions. For all users, run
`sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation.

View File

@ -79,7 +79,7 @@ SUBCOMMANDS
[--scale f] [--quality 0-100] [--no-audio] [--queue]
export the active sequence and wait for it to finish: with an
export preset (`export --list-presets`; built-in or the user's), or
a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed
a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed
from the extension); --range entire|inOut|workArea, or a custom
range in seconds; --settings is ExportSettings JSON merged over the
preset; --scale renders at a fraction of the frame size (0.5 =
@ -209,6 +209,7 @@ fn format_for(path: &str) -> Option<&'static str> {
"tif" | "tiff" => "tiff",
"bmp" => "bmp",
"aif" | "aiff" => "aiff",
"m4a" | "aac" => "aac",
_ => return None,
})
}

View File

@ -2,23 +2,23 @@
"schema": 1,
"repo": "storytold/filmcraft",
"branch": "main",
"head": "7b6c134472287db4367fca80bcb498240ba36567",
"head_date": "2026-10-09T15:02:38Z",
"commits": 539,
"head": "9efe8f3112194b58a30cf0449b569306077c67d4",
"head_date": "2026-10-10T22:34:28Z",
"commits": 812,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 48,
"commits": 337,
"lines_added": 311167,
"lines_deleted": 13200,
"prs": 49,
"commits": 366,
"lines_added": 313219,
"lines_deleted": 14128,
"binary_added": 74,
"binary_deleted": 0,
"first_commit": "2026-09-30T09:22:00Z",
"last_commit": "2026-10-09T15:02:38Z"
"last_commit": "2026-10-10T20:08:58Z"
},
{
"login": "storybored379",
@ -154,9 +154,9 @@
"login": "carlitosteso3",
"people_entry": false,
"github_name": null,
"prs": 15,
"commits": 15,
"lines_added": 1740,
"prs": 16,
"commits": 16,
"lines_added": 2009,
"lines_deleted": 207,
"binary_added": 0,
"binary_deleted": 0,
@ -232,27 +232,53 @@
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 209,
"lines_deleted": 0,
"prs": 3,
"commits": 3,
"lines_added": 441,
"lines_deleted": 10,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T22:57:38Z",
"last_commit": "2026-10-08T23:52:30Z"
"last_commit": "2026-10-09T22:01:08Z"
},
{
"login": "txiki739",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 8,
"lines_added": 7976,
"lines_deleted": 3333,
"prs": 8,
"commits": 11,
"lines_added": 8160,
"lines_deleted": 3347,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T23:41:48Z",
"last_commit": "2026-10-09T09:28:24Z"
"last_commit": "2026-10-09T14:06:49Z"
},
{
"login": "mutombopr",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 19,
"lines_added": 10421,
"lines_deleted": 337,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T03:57:26Z",
"last_commit": "2026-10-10T17:46:20Z"
},
{
"login": "ahmedhosnypro",
"people_entry": true,
"github_name": null,
"prs": 2,
"commits": 5,
"lines_added": 473,
"lines_deleted": 80,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T05:32:08Z",
"last_commit": "2026-10-09T22:00:30Z"
},
{
"login": "wstock",
@ -284,14 +310,14 @@
"login": "MrCloudy2",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 132,
"lines_deleted": 4,
"prs": 6,
"commits": 7,
"lines_added": 1293,
"lines_deleted": 46,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T08:53:54Z",
"last_commit": "2026-10-08T08:53:54Z"
"last_commit": "2026-10-10T15:14:34Z"
},
{
"login": "switch87",
@ -336,27 +362,27 @@
"login": "MarekSpodBiedry",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 74,
"lines_deleted": 6,
"prs": 29,
"commits": 29,
"lines_added": 1048,
"lines_deleted": 137,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:44:56Z",
"last_commit": "2026-10-09T08:45:28Z"
"last_commit": "2026-10-10T21:22:52Z"
},
{
"login": "Tom1tk",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 47,
"lines_deleted": 26,
"prs": 3,
"commits": 3,
"lines_added": 70,
"lines_deleted": 30,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:47:40Z",
"last_commit": "2026-10-08T14:56:39Z"
"last_commit": "2026-10-09T14:19:08Z"
},
{
"login": "Pathos0925",
@ -375,14 +401,14 @@
"login": "mjohnson7cd",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 1,
"lines_added": 100,
"lines_deleted": 2,
"prs": 3,
"commits": 2,
"lines_added": 121,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T20:36:28Z",
"last_commit": "2026-10-08T20:36:28Z"
"last_commit": "2026-10-10T00:47:33Z"
},
{
"login": "Oldrich333",
@ -401,14 +427,14 @@
"login": "cshapiro",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 5,
"lines_added": 3128,
"lines_deleted": 111,
"prs": 4,
"commits": 6,
"lines_added": 3165,
"lines_deleted": 116,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T23:28:32Z",
"last_commit": "2026-10-09T02:57:30Z"
"last_commit": "2026-10-09T15:59:56Z"
},
{
"login": "rossaai",
@ -440,14 +466,14 @@
"login": "JordyUSA",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 3,
"lines_added": 4158,
"lines_deleted": 283,
"prs": 6,
"commits": 9,
"lines_added": 4948,
"lines_deleted": 405,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T01:35:57Z",
"last_commit": "2026-10-09T02:53:28Z"
"last_commit": "2026-10-10T15:06:45Z"
},
{
"login": "jerrykimo",
@ -466,14 +492,27 @@
"login": "swillunderscore",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 3,
"lines_added": 1008,
"lines_deleted": 13,
"prs": 2,
"commits": 6,
"lines_added": 2407,
"lines_deleted": 63,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T01:58:37Z",
"last_commit": "2026-10-09T02:19:21Z"
"last_commit": "2026-10-10T00:20:25Z"
},
{
"login": "jnohclee-rgb",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 19,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T02:14:06Z",
"last_commit": "2026-10-09T02:14:06Z"
},
{
"login": "IppSec",
@ -492,14 +531,14 @@
"login": "sanyamsingh001",
"people_entry": true,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 323,
"lines_deleted": 25,
"prs": 5,
"commits": 5,
"lines_added": 1169,
"lines_deleted": 93,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T04:06:37Z",
"last_commit": "2026-10-09T05:44:39Z"
"last_commit": "2026-10-09T23:00:34Z"
},
{
"login": "ItzBloxy",
@ -513,6 +552,435 @@
"binary_deleted": 0,
"first_commit": "2026-10-09T09:27:48Z",
"last_commit": "2026-10-09T10:17:08Z"
},
{
"login": "dmatviichuk",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 464,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:18:42Z",
"last_commit": "2026-10-09T12:18:42Z"
},
{
"login": "David-T-Campos",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 7,
"lines_added": 61,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T15:25:06Z",
"last_commit": "2026-10-09T15:27:55Z"
},
{
"login": "lloyd-nich",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 3,
"lines_added": 1477,
"lines_deleted": 121,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T15:58:49Z",
"last_commit": "2026-10-10T07:02:57Z"
},
{
"login": "haranishi",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 3700,
"lines_deleted": 92,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T17:14:19Z",
"last_commit": "2026-10-10T08:34:16Z"
},
{
"login": "vbasky",
"people_entry": false,
"github_name": null,
"prs": 10,
"commits": 11,
"lines_added": 1314,
"lines_deleted": 215,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T17:37:44Z",
"last_commit": "2026-10-10T16:41:59Z"
},
{
"login": "ossini",
"people_entry": true,
"github_name": "Oskar Sobania",
"prs": 2,
"commits": 12,
"lines_added": 1381,
"lines_deleted": 149,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T18:43:22Z",
"last_commit": "2026-10-10T17:17:47Z"
},
{
"login": "liangforstudy",
"people_entry": true,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 575,
"lines_deleted": 20,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T21:22:05Z",
"last_commit": "2026-10-10T09:40:58Z"
},
{
"login": "ReReddo",
"people_entry": true,
"github_name": null,
"prs": 12,
"commits": 14,
"lines_added": 1918,
"lines_deleted": 126,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T21:30:29Z",
"last_commit": "2026-10-10T19:45:20Z"
},
{
"login": "MasakatsuFunaki",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 261,
"lines_deleted": 13,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T21:49:24Z",
"last_commit": "2026-10-09T21:49:24Z"
},
{
"login": "maccagnit",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 221,
"lines_deleted": 30,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T23:00:05Z",
"last_commit": "2026-10-10T15:22:56Z"
},
{
"login": "digitaldecaycreations-lgtm",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 11,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T23:20:58Z",
"last_commit": "2026-10-09T23:20:58Z"
},
{
"login": "dylanmtaylor",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 37,
"lines_deleted": 42,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T00:04:17Z",
"last_commit": "2026-10-10T00:04:17Z"
},
{
"login": "sawizzle",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 23,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T00:06:15Z",
"last_commit": "2026-10-10T04:50:49Z"
},
{
"login": "luniiya",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 697,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T00:14:55Z",
"last_commit": "2026-10-10T00:14:55Z"
},
{
"login": "chuygarcia92",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 38,
"lines_deleted": 33,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T02:20:17Z",
"last_commit": "2026-10-10T02:20:17Z"
},
{
"login": "Hathor875",
"people_entry": true,
"github_name": "Krzysztof Cieślik",
"prs": 1,
"commits": 1,
"lines_added": 109,
"lines_deleted": 4,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T02:21:06Z",
"last_commit": "2026-10-10T02:21:06Z"
},
{
"login": "Guilhermeb-Ferrarezi",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 161,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T02:43:20Z",
"last_commit": "2026-10-10T02:48:16Z"
},
{
"login": "mitchmckinstry",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 3,
"lines_added": 399,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T03:14:19Z",
"last_commit": "2026-10-10T03:25:58Z"
},
{
"login": "gyroninja",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 178,
"lines_deleted": 12,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T04:32:42Z",
"last_commit": "2026-10-10T10:14:18Z"
},
{
"login": "hadmark",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 4,
"lines_added": 3608,
"lines_deleted": 4,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T06:08:41Z",
"last_commit": "2026-10-10T06:32:49Z"
},
{
"login": "SilentYeti",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 3,
"lines_added": 1562,
"lines_deleted": 72,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T06:17:11Z",
"last_commit": "2026-10-10T09:17:16Z"
},
{
"login": "adurr21",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 287,
"lines_deleted": 68,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T07:03:03Z",
"last_commit": "2026-10-10T07:03:03Z"
},
{
"login": "maxm11",
"people_entry": true,
"github_name": null,
"prs": 3,
"commits": 6,
"lines_added": 487,
"lines_deleted": 38,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T07:19:38Z",
"last_commit": "2026-10-10T14:18:27Z"
},
{
"login": "Ni-zav",
"people_entry": false,
"github_name": null,
"prs": 10,
"commits": 13,
"lines_added": 498,
"lines_deleted": 65,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T08:47:24Z",
"last_commit": "2026-10-10T21:55:59Z"
},
{
"login": "ivandrozdov1505-coder",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 404,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T10:40:22Z",
"last_commit": "2026-10-10T10:40:22Z"
},
{
"login": "KattKnight",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 3,
"lines_added": 335,
"lines_deleted": 9,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T10:56:07Z",
"last_commit": "2026-10-10T13:00:21Z"
},
{
"login": "keller-lukas",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 480,
"lines_deleted": 15,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T11:06:02Z",
"last_commit": "2026-10-10T11:06:02Z"
},
{
"login": "4papuce",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 39,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T12:43:28Z",
"last_commit": "2026-10-10T12:43:28Z"
},
{
"login": "vangel76",
"people_entry": true,
"github_name": null,
"prs": 49,
"commits": 53,
"lines_added": 11727,
"lines_deleted": 752,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T13:47:11Z",
"last_commit": "2026-10-10T22:34:28Z"
},
{
"login": "abdelkabirouadoukou",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 30,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T15:54:00Z",
"last_commit": "2026-10-10T15:54:00Z"
},
{
"login": "impostazioni",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 596,
"lines_deleted": 34,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T17:29:09Z",
"last_commit": "2026-10-10T20:52:28Z"
},
{
"login": "alex-brazh",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 159,
"lines_deleted": 4,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T19:55:52Z",
"last_commit": "2026-10-10T19:55:52Z"
},
{
"login": "LloydNicholson",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
}
],
"models": [
@ -521,27 +989,27 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 474,
"lines_added": 346706,
"lines_deleted": 19057
"commits": 602,
"lines_added": 374868,
"lines_deleted": 20972
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 18,
"lines_added": 13935,
"lines_deleted": 854
"commits": 25,
"lines_added": 18034,
"lines_deleted": 957
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 11,
"lines_added": 2057,
"lines_deleted": 361
"commits": 16,
"lines_added": 3654,
"lines_deleted": 503
},
{
"id": "anthropic/claude-fable/5.1",
@ -557,9 +1025,9 @@
"company": "Anthropic",
"model": "Claude",
"version": "",
"commits": 1,
"lines_added": 235,
"lines_deleted": 22
"commits": 2,
"lines_added": 496,
"lines_deleted": 35
},
{
"id": "anthropic/claude-opus/4.8",

View File

@ -1,6 +1,7 @@
//! Image sequences and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, JPEG,
//! TIFF, BMP, WebP when available) and BWF files; the sequence frames must equal ffmpeg's decode of
//! the same image2 sequence (lossless formats exactly, JPEG within a few levels), and the BWF
//! Image sequences, stills and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG,
//! JPEG, TIFF, BMP, WebP when available, Targa, DPX), single Targa / DPX stills in every layout it
//! has, and BWF files; our frames must equal ffmpeg's decode of the same files (lossless formats
//! exactly, JPEG within a few levels, high bit depths within one 8-bit step), and the BWF
//! TimeReference must give the start timecode.
mod common;
@ -114,6 +115,62 @@ fn webp_sequence_lossless() {
check(&ff, "webp", &["-c:v", "libwebp", "-lossless", "1"], 0);
}
#[test]
fn targa_and_dpx_sequences_exact() {
let ff = filmcraft_testkit::require_ffmpeg!();
check(&ff, "tga", &[], 0);
check(&ff, "dpx", &["-pix_fmt", "gbrp10be"], 1);
}
/// One `ext` still in `pix_fmt` (95 × 63, so scan lines need padding; translucent when the format
/// has alpha) opened like an imported file must match ffmpeg's RGBA decode within `tol` (exactly
/// when `tol` is 0).
fn still_matches(ff: &Path, ext: &str, pix_fmt: &str, extra: &[&str], tol: u8) {
let name = format!("still_{pix_fmt}{}.{ext}", extra.join(""));
let alpha = if pix_fmt.contains('a') && pix_fmt != "pal8" { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" };
let vf = format!("scale=95:63{alpha}");
let mut args = vec!["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-pix_fmt", pix_fmt];
args.extend_from_slice(extra);
let Some(f) = fixture(ff, &name, &args) else {
eprintln!("SKIPPED: ffmpeg cannot write {name}");
return;
};
let src = filmcraft_codecs::open_bytes(&name, bytes(&f)).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(src.info().kind, MediaKind::Still, "{name}");
let v = src.info().video.as_ref().unwrap();
assert_eq!((v.width, v.height), (95, 63), "{name}");
let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap());
// deeper than 8 bits: ffmpeg's 16-bit decode, rounded to 8 bits (its own 8-bit conversion dithers)
let want: Vec<u8> = if tol == 0 {
ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba", "-"])
} else {
let wide = ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]);
wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect()
};
assert_eq!(ours.len(), want.len(), "{name}");
let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(d <= tol, "{name}: differs by {d}");
assert_eq!(v.has_alpha, want.as_chunks::<4>().0.iter().any(|p| p[3] != 255), "{name}");
eprintln!("{name}: max diff {d}");
}
#[test]
fn targa_stills_every_layout() {
let ff = filmcraft_testkit::require_ffmpeg!();
for pix_fmt in ["bgr24", "bgra", "rgb555le", "gray", "pal8"] {
still_matches(&ff, "tga", pix_fmt, &[], 0); // run-length encoded (ffmpeg's default)
still_matches(&ff, "tga", pix_fmt, &["-rle", "0"], 0);
}
}
#[test]
fn dpx_stills_every_layout() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (pix_fmt, tol) in [("rgb24", 0), ("rgba", 0), ("gray", 0), ("rgb48be", 1), ("rgba64be", 1), ("gray16be", 1), ("gbrp10be", 1), ("gbrp12be", 1)] {
still_matches(&ff, "dpx", pix_fmt, &[], tol);
}
}
#[test]
fn missing_frames_hold_the_previous_frame() {
let ff = filmcraft_testkit::require_ffmpeg!();

View File

@ -1285,7 +1285,7 @@ fn build() -> Vec<CommandSpec> {
"Media…",
["File", "Export"],
None,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff|aac"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
has_seq,
crate::export_tools::export_media
),

View File

@ -178,6 +178,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]);
continue;
}
"aac" => {
// AAC Audio: an MPEG-4 audio file, sound only
assert!(out.ends_with(".m4a"), "{name}: {out}");
assert!(fmt_name.contains("m4a"), "{name}: {fmt_name}");
assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}");
let a = stream(&j, "audio").unwrap();
assert_eq!((a["codec_name"].as_str(), a["sample_rate"].as_str(), a["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)), "{name}");
assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.03, "{name}: {}", j["format"]["duration"]);
continue;
}
"gif" => {
let v = stream(&j, "video").unwrap();
assert_eq!(v["codec_name"], "gif");
@ -282,6 +292,25 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
/// H.264 bitrate control over three seconds of the demo: the adaptive Match Source preset
/// targets width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's
/// measured video bitrate stays within the VBR maximum and close to the target.
#[test]
fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() {
let mut s = demo();
let dir = Scratch::new("aac-audio");
let range = json!({"range": "custom", "startSeconds": 1.0, "endSeconds": 1.25, "wait": true, "format": "aac"});
let run = |s: &mut Session, path: String| {
let mut p = range.clone();
p["path"] = json!(path);
s.execute("file.exportMedia", p).unwrap()["path"].as_str().unwrap().to_string()
};
let adts = run(&mut s, dir.path("Mix.aac"));
assert!(adts.ends_with("Mix.aac"), "{adts}");
let b = std::fs::read(&adts).unwrap();
assert_eq!((b[0], b[1]), (0xFF, 0xF1), "an ADTS header");
let m4a = run(&mut s, dir.path("Mix"));
assert!(m4a.ends_with("Mix.m4a"), "{m4a}");
assert_eq!(&std::fs::read(&m4a).unwrap()[4..8], b"ftyp");
}
#[test]
fn h264_presets_hit_their_bitrates() {
let mut s = demo();

View File

@ -363,7 +363,8 @@ fn with_extension(path: &str, settings: &ExportSettings) -> String {
Some(e)
if e.eq_ignore_ascii_case(ext)
|| (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff"))
|| (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) =>
|| (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg"))
|| (settings.format == Format::Aac && e.eq_ignore_ascii_case("aac")) =>
{
path.to_string()
}

View File

@ -38,7 +38,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
spec(
"masks.add",
"Create Mask",
r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?}"#,
r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"rectangle"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?,"cornerRadius":px?}"#,
add,
),
spec("masks.remove", "Delete Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?}"#, remove),
@ -64,6 +64,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
track,
),
spec("masks.toggleVertexSmooth", "Convert Mask Vertex", r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n}"#, toggle_smooth),
spec("masks.roundCorners", "Round Mask Corners", r#"{"clip":id?,"effect":index|id?,"mask":n?,"radius":px,"vertices":[n]?}"#, round_corners),
CommandSpec {
id: "masks.select",
label: "Select Mask",
@ -212,7 +213,12 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
None => match shape.as_str() {
"ellipse" => MaskPath::ellipse(center, size * 0.5),
"polygon" | "rectangle" | "rect" | "4-point" => {
MaskPath::rect(center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0)
let (x0, y0, x1, y1) = (center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0);
match f64_p(p, "cornerRadius") {
Some(r) if !r.is_finite() || r < 0.0 => return Err(bad("masks.add", "`cornerRadius` is pixels ≥ 0")),
Some(r) => MaskPath::rounded_rect(x0, y0, x1, y1, r),
None => MaskPath::rect(x0, y0, x1, y1),
}
}
_ => return Err(bad("masks.add", "a bezier mask needs `path` (pen points)")),
},
@ -454,6 +460,40 @@ fn toggle_smooth(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Round the mask's sharp corners (Premiere 26's rounded rectangle masks): every corner between
/// two straight edges (or only the listed `vertices`) becomes a circular fillet of `radius`
/// pixels, shrunk to fit half of each edge. The same corners are rounded in every Mask Path
/// keyframe so an animated path keeps interpolating. Returns how many corners were rounded.
fn round_corners(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "masks.roundCorners";
let radius = f64_p(p, "radius").filter(|r| r.is_finite() && *r > 0.0).ok_or_else(|| bad(ID, "need `radius` (pixels, > 0)"))?;
let only: Option<Vec<usize>> = match p.get("vertices") {
None | Some(Value::Null) => None,
Some(v) => {
let list = v.as_array().ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))?;
let mut out = list
.iter()
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices")))
.collect::<Result<Vec<_>>>()?;
out.sort_unstable();
out.dedup();
Some(out)
}
};
let rounded = edit_mask(s, p, "Round Mask Corners", move |m, t| {
let mut corners = m.path_at(t).roundable_corners();
if let Some(only) = &only {
corners.retain(|i| only.binary_search(i).is_ok());
}
if corners.is_empty() {
return Err(bad(ID, "no sharp corner between straight edges to round"));
}
drop_all(m, t, |path| *path = path.with_rounded_corners(radius, &corners));
Ok(corners.len())
})?;
Ok(json!({"corners": rounded}))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
if bool_p(p, "none") == Some(true) || p.is_null() || p.as_object().is_some_and(|o| o.is_empty()) {
s.state.selected_mask = None;

View File

@ -317,3 +317,66 @@ fn tracking_runs_in_the_background_and_can_be_cancelled() {
assert!(n < 48, "stopped early ({n} keyframes)");
assert!(s.execute("masks.track", json!({"direction": "backward"})).is_err(), "nothing before the first frame");
}
/// #813: a rectangle mask with rounded corners (created rounded, or rounded afterwards) cuts the
/// clip's corners away while the edges stay where they were; one undo step, every keyframe.
#[test]
fn rounded_rectangle_masks_cut_their_corners() {
let (mut s, clip) = demo();
// a 960×540 opacity mask in the middle of the 1920×1080 frame, rounded by 200 px
s.execute("masks.add", json!({"clip": clip.0, "shape": "rectangle", "center": [960, 540], "size": [960, 540], "cornerRadius": 200})).unwrap();
s.execute("masks.set", json!({"feather": 0})).unwrap();
let list = s.execute("masks.list", json!({})).unwrap();
assert_eq!(list["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 8);
let img = s.render_program(0.25).unwrap();
// just inside the rectangle's corner: cut away; middle of an edge and the centre: kept
let corner = px(&img, 0.26, 0.26);
assert!(corner[0] + corner[1] + corner[2] < 1e-4, "rounded corner cut: {corner:?}");
assert!(px(&img, 0.5, 0.26)[3] > 0.99, "top edge kept");
assert!(px(&img, 0.5, 0.5)[3] > 0.99, "inside kept");
// round an existing 4-point mask: same picture, one undo step back to sharp corners
s.undo();
s.undo();
s.execute("masks.add", json!({"clip": clip.0, "shape": "polygon", "center": [960, 540], "size": [960, 540]})).unwrap();
s.execute("masks.set", json!({"feather": 0})).unwrap();
let sharp = s.render_program(0.25).unwrap();
assert!(px(&sharp, 0.26, 0.26)[3] > 0.99, "a sharp corner keeps the clip");
let r = s.execute("masks.roundCorners", json!({"radius": 200})).unwrap();
assert_eq!(r["corners"], 4);
assert_eq!(s.render_program(0.25).unwrap().px, img.px, "rounding afterwards = creating rounded");
// nothing left to round; bad radii are refused without changing the mask
assert!(s.execute("masks.roundCorners", json!({"radius": 50})).is_err());
for radius in [json!(0), json!(-5), json!("big"), json!(null)] {
assert!(s.execute("masks.roundCorners", json!({"radius": radius})).is_err(), "{radius}");
}
s.undo();
assert_eq!(s.render_program(0.25).unwrap().px, sharp.px, "one undo step restores the sharp corners");
}
#[test]
fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners() {
let (mut s, clip) = demo();
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
s.set_playhead(it.start);
s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap();
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "path"})).unwrap();
let rate = s.active_sequence().unwrap().settings.frame_rate;
s.set_playhead(it.start + rate.tick_of(10));
s.execute("masks.translate", json!({"delta": [300, 0]})).unwrap();
// only vertices 0 and 2 (top-left, bottom-right), out-of-range indices ignored
let r = s.execute("masks.roundCorners", json!({"radius": 30, "vertices": [0, 2, 99]})).unwrap();
assert_eq!(r["corners"], 2);
let m = &s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.iter().find(|e| e.effect == "opacity").unwrap().masks[0];
assert_eq!(m.path.keyframes.len(), 2);
for k in &m.path.keyframes {
let filmcraft_project::ParamValue::Path(p) = &k.value else { panic!("path keyframe") };
assert_eq!(p.len(), 6, "both keyframes rounded the same corners");
assert_eq!(p.vertices.iter().filter(|v| v.is_corner()).count(), 2, "vertices 1 and 3 stay sharp");
}
// halfway the path still interpolates (equal vertex counts)
let l = s.execute("masks.list", json!({"time": (it.start + rate.tick_of(5)).0})).unwrap();
let v = l["masks"][0]["path"]["vertices"].as_array().unwrap();
assert_eq!(v.len(), 6);
assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]);
}

View File

@ -0,0 +1,201 @@
//! AAC Audio: the sequence mix alone, AAC-LC from the same encoder as the MPEG-4 movies, in an
//! MPEG-4 audio file (`.m4a`, the encoder delay signalled by an edit list) or, when the path ends
//! in `.aac`, a raw ADTS stream (ISO/IEC 14496-3 §1.A.2; one header per access unit, no CRC). ADTS
//! cannot signal the encoder delay, so a player starts it with the priming samples.
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use filmcraft_isobmff::{Brand, CodecConfig, Mp4Writer, TrackConfig, WriteSample, WriterOptions};
use filmcraft_project::{ItemId, Project};
use filmcraft_render::SourceProvider;
use filmcraft_time::TimeRange;
use crate::audio_out::AudioOut;
use crate::{AudioEncoder, ExportError, ExportSettings, Format, Out, Progress, Result, audio_factories};
/// Whether `path` asks for a raw ADTS stream rather than an MPEG-4 file.
pub(crate) fn is_adts_path(path: &str) -> bool {
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("aac"))
}
/// Where the encoded access units go.
enum Sink {
Mp4 { mux: Mp4Writer<Out>, track: usize, frame: u32 },
Adts { out: Out, header: AdtsHeader },
}
impl Sink {
fn write(&mut self, units: Vec<Vec<u8>>) -> Result<()> {
let io = |e: std::io::Error| ExportError::Io(e.to_string());
match self {
Sink::Mp4 { mux, track, frame } => {
for au in units {
mux.write_sample(*track, WriteSample { data: &au, duration: *frame, composition_offset: 0, is_sync: true })
.map_err(|e| ExportError::Io(e.to_string()))?;
}
}
Sink::Adts { out, header } => {
for au in units {
out.write_all(&header.bytes(au.len())?).map_err(io)?;
out.write_all(&au).map_err(io)?;
}
}
}
Ok(())
}
fn finish(self, settings: &ExportSettings) -> Result<u64> {
match self {
Sink::Mp4 { mux, .. } => mux.finish().map_err(|e| ExportError::Io(e.to_string()))?.finish(settings),
Sink::Adts { out, .. } => out.finish(settings),
}
}
}
/// The fixed fields of an ADTS header, from the AudioSpecificConfig.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct AdtsHeader {
/// `profile_ObjectType`: the audio object type minus one (1 = AAC-LC).
profile: u8,
sf_index: u8,
channel_config: u8,
}
impl AdtsHeader {
/// Read the object type, sampling frequency index and channel configuration from an
/// AudioSpecificConfig; ADTS can carry only object types 1–4 with an indexed rate and a
/// channel configuration of 1–7.
pub(crate) fn from_asc(asc: &[u8]) -> Result<AdtsHeader> {
let (Some(&a), Some(&b)) = (asc.first(), asc.get(1)) else {
return Err(ExportError::Encode("AAC: the encoder gave no AudioSpecificConfig".into()));
};
let object_type = a >> 3;
let sf_index = ((a & 0x07) << 1) | (b >> 7);
let channel_config = (b >> 3) & 0x0F;
if !(1..=4).contains(&object_type) || sf_index > 12 || !(1..=7).contains(&channel_config) {
return Err(ExportError::Unsupported(format!(
"an ADTS .aac stream cannot carry this AAC configuration (object type {object_type}, rate index {sf_index}, channels {channel_config}): export .m4a instead"
)));
}
Ok(AdtsHeader { profile: object_type - 1, sf_index, channel_config })
}
/// The 7-byte header (`protection_absent`) of an access unit of `payload` bytes.
pub(crate) fn bytes(&self, payload: usize) -> Result<[u8; 7]> {
// aac_frame_length is 13 bits and counts the header
let too_big = || ExportError::Encode(format!("AAC: a {payload}-byte frame does not fit ADTS"));
let len = u32::try_from(payload).ok().and_then(|n| n.checked_add(7)).filter(|n| *n < 1 << 13).ok_or_else(too_big)?;
Ok([
0xFF,
0xF1, // syncword, MPEG-4, layer 0, protection absent
(self.profile << 6) | (self.sf_index << 2) | (self.channel_config >> 2),
((self.channel_config & 0x03) << 6) | ((len >> 11) & 0x03) as u8,
((len >> 3) & 0xFF) as u8,
(((len & 0x07) << 5) as u8) | 0x1F, // buffer fullness 0x7FF: variable rate
0xFC, // … and one raw data block
])
}
}
/// Encode the mix of `range` to `settings.path`; returns (bytes written, samples per channel).
pub(crate) fn export(
project: &Arc<Project>,
seq: ItemId,
settings: &ExportSettings,
range: TimeRange,
sources: &dyn SourceProvider,
progress: &Progress,
) -> Result<(u64, u64)> {
let cancelled = || progress.cancel.load(Ordering::Relaxed);
let mut a = AudioOut::new(project.clone(), seq, settings, range)?;
let mut enc: Box<dyn AudioEncoder> = audio_factories()
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
.find_map(|f| f(Format::Aac, a.sr, a.channels as u32, settings))
.ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??;
let entry = enc.sample_entry();
let frames = a.remaining();
// one second per chunk: memory stays flat however long the range is
let chunk = i64::from(a.sr.max(1));
if !settings.part_of_batch {
progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed);
progress.set_status(format!("Exporting audio ({})", settings.format.label()));
}
a.measure(settings, sources, &cancelled)?;
*progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness;
let mut sink = if is_adts_path(&settings.path) {
let CodecConfig::Aac(cfg) = &entry.codec else {
return Err(ExportError::Encode("AAC: the encoder's sample entry is not AAC".into()));
};
Sink::Adts { header: AdtsHeader::from_asc(&cfg.asc)?, out: Out::create(settings)? }
} else {
let mut opts = WriterOptions::new(Brand::Mp4);
opts.metadata = settings.metadata.udta();
let mut mux = Mp4Writer::new(Out::create(settings)?, opts).map_err(|e| ExportError::Io(e.to_string()))?;
let mut track = TrackConfig::new(entry, a.sr);
track.media_start = Some(i64::from(enc.priming()));
let track = mux.add_track(track).map_err(|e| ExportError::Io(e.to_string()))?;
Sink::Mp4 { mux, track, frame: enc.frame_size() }
};
while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) {
if cancelled() {
return Err(ExportError::Cancelled);
}
sink.write(enc.encode(&planar)?)?;
if !settings.part_of_batch {
progress.done.fetch_add(1, Ordering::Relaxed);
}
}
sink.write(enc.flush()?)?;
let n = sink.finish(settings)?;
if settings.part_of_batch {
progress.done.store(1, Ordering::Relaxed);
}
Ok((n, frames))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adts_header_fields_follow_the_asc() {
// AAC-LC (2), 48 kHz (index 3), stereo: 00010 0011 0010 000
let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap();
assert_eq!(h, AdtsHeader { profile: 1, sf_index: 3, channel_config: 2 });
let b = h.bytes(371).unwrap();
assert_eq!(&b[..2], &[0xFF, 0xF1]);
assert_eq!(b[2] >> 6, 1);
assert_eq!((b[2] >> 2) & 0x0F, 3);
assert_eq!(((b[2] & 1) << 2) | (b[3] >> 6), 2);
let len = (u32::from(b[3] & 0x03) << 11) | (u32::from(b[4]) << 3) | u32::from(b[5] >> 5);
assert_eq!(len, 378);
assert_eq!(((u32::from(b[5] & 0x1F)) << 6) | u32::from(b[6] >> 2), 0x7FF);
assert_eq!(b[6] & 0x03, 0);
}
#[test]
fn adts_refuses_what_it_cannot_signal() {
assert!(AdtsHeader::from_asc(&[]).is_err());
assert!(AdtsHeader::from_asc(&[0x11]).is_err());
// explicit sampling frequency (index 15)
assert!(AdtsHeader::from_asc(&[0x17, 0x90, 0, 0, 0]).is_err());
// object type 5 (SBR) has no ADTS profile
assert!(AdtsHeader::from_asc(&[0x29, 0x90]).is_err());
let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap();
assert!(h.bytes(8184).is_ok());
assert!(h.bytes(8185).is_err());
assert!(h.bytes(usize::MAX).is_err());
}
#[test]
fn only_a_dot_aac_path_is_adts() {
assert!(is_adts_path("/x/mix.aac"));
assert!(is_adts_path("mix.AAC"));
assert!(!is_adts_path("mix.m4a"));
assert!(!is_adts_path("aac"));
}
}

View File

@ -15,6 +15,7 @@
use std::io::Write;
mod aac_out;
mod audio_out;
mod job;
mod mxf_out;
@ -115,6 +116,10 @@ pub enum Format {
Wav,
#[serde(rename = "aiff", alias = "Aiff")]
Aiff,
/// AAC Audio: the mix alone, AAC-LC in an MPEG-4 audio file (`.m4a`), or a raw ADTS stream when
/// the path ends in `.aac`.
#[serde(rename = "aac", alias = "Aac")]
Aac,
/// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM.
#[serde(rename = "mxf-op1a", alias = "MxfOp1a")]
MxfOp1a,
@ -147,6 +152,7 @@ impl Format {
"gif" | "animatedgif" => Format::Gif,
"wav" | "waveform" | "waveformaudio" => Format::Wav,
"aif" | "aiff" | "aifc" => Format::Aiff,
"aac" | "aacaudio" | "m4a" | "adts" => Format::Aac,
_ => return None,
})
}
@ -170,6 +176,7 @@ impl Format {
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aiff",
Format::Aac => "aac",
Format::MxfOp1a => "mxf-op1a",
Format::MxfOpAtom => "mxf-opatom",
}
@ -188,6 +195,7 @@ impl Format {
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aif",
Format::Aac => "m4a",
Format::MxfOp1a | Format::MxfOpAtom => "mxf",
}
}
@ -210,6 +218,7 @@ impl Format {
Format::Gif => "Animated GIF",
Format::Wav => "Waveform Audio",
Format::Aiff => "AIFF",
Format::Aac => "AAC Audio",
Format::MxfOp1a => "MXF OP1a",
Format::MxfOpAtom => "MXF OP-Atom",
}
@ -236,6 +245,10 @@ impl Format {
pub fn has_builtin_encoder(self) -> bool {
!matches!(self, Format::Hevc | Format::Av1)
}
/// Sound without picture: Waveform Audio, AIFF and AAC Audio.
pub fn is_audio_only(self) -> bool {
matches!(self, Format::Wav | Format::Aiff | Format::Aac)
}
/// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls.
pub fn is_h26x(self) -> bool {
matches!(self, Format::H264 | Format::Hevc)
@ -248,7 +261,7 @@ impl Format {
pub fn is_mp4_with(self, mux: Multiplexer) -> bool {
self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4)
}
pub const ALL: [Format; 19] = [
pub const ALL: [Format; 20] = [
Format::H264,
Format::Hevc,
Format::Av1,
@ -266,6 +279,7 @@ impl Format {
Format::Gif,
Format::Wav,
Format::Aiff,
Format::Aac,
Format::MxfOp1a,
Format::MxfOpAtom,
];
@ -1739,6 +1753,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
let cancelled = || progress.cancel.load(Ordering::Relaxed);
let batch = rayon::current_num_threads().clamp(2, 16) as i64;
let (bytes, nframes) = match settings.format {
Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?,
Format::Wav | Format::Aiff => {
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);

View File

@ -175,6 +175,16 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
p("Waveform Audio 48 kHz 16-bit", "Audio Only", "WAV, stereo 48 kHz 16-bit PCM", audio_only(Format::Wav, 16)),
p("Waveform Audio 48 kHz 24-bit", "Audio Only", "WAV, stereo 48 kHz 24-bit PCM", audio_only(Format::Wav, 24)),
p("AIFF 48 kHz 16-bit", "Audio Only", "AIFF, stereo 48 kHz 16-bit PCM", audio_only(Format::Aiff, 16)),
p(
"AAC Audio 48 kHz 320 kbps",
"Audio Only",
"MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps",
ExportSettings {
format: Format::Aac,
audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 },
..Default::default()
},
),
]
}

View File

@ -160,7 +160,7 @@ impl MxfVideoCodec {
}
}
/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF.
/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AudioCodec {
@ -432,6 +432,7 @@ impl ExportSettings {
pub fn audio_codec(&self) -> AudioCodec {
match (self.format, self.audio.codec) {
(Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm,
(Format::Aac, _) => AudioCodec::Aac,
(f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac,
(_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac,
(_, AudioCodec::Auto) => AudioCodec::Pcm,
@ -446,7 +447,7 @@ impl ExportSettings {
/// Whether the format carries video.
pub fn has_video(&self) -> bool {
!matches!(self.format, Format::Wav | Format::Aiff)
!self.format.is_audio_only()
}
/// File extension of the output.
@ -461,6 +462,7 @@ impl ExportSettings {
let fps = r.rate.num as f64 / r.rate.den as f64;
let px = r.width as f64 * r.height as f64;
let video_bps = match self.video_format() {
Format::Aac => 0.0,
Format::H264 | Format::Hevc | Format::Av1 => r.target_kbps as f64 * 1000.0,
Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97,
Format::DnxHr => {
@ -493,7 +495,7 @@ impl ExportSettings {
Format::Gif => px * 0.6 * 8.0 * fps,
Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
};
let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) {
let audio_bps = if self.has_audio() || self.format.is_audio_only() {
match self.audio_codec() {
AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0,
_ => r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 },
@ -578,7 +580,7 @@ impl ExportSettings {
}
v
};
let audio = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) {
let audio = if self.has_audio() || self.format.is_audio_only() {
let ch = match r.channels {
1 => "Mono",
6 => "5.1",

View File

@ -261,6 +261,103 @@ fn wav_and_aiff_audio_only() {
}
}
/// Root-mean-square level of decoded planar audio.
fn rms(planes: &[Vec<f32>]) -> f32 {
let n = planes.iter().map(Vec::len).sum::<usize>().max(1);
(planes.iter().flatten().map(|s| s * s).sum::<f32>() / n as f32).sqrt()
}
#[test]
fn aac_audio_writes_m4a_and_adts() {
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
let dir = Scratch::new("aac-audio");
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND)));
// .m4a: one AAC track, no video, the encoder delay in an edit list
let m4a = dir.path("a.m4a");
let s = ExportSettings { format: Format::Aac, path: m4a.clone(), range, ..Default::default() };
assert!(!s.has_video());
assert_eq!(s.audio_codec(), AudioCodec::Aac);
assert_eq!(s.extension(), "m4a");
let r = export(&p, seq, &s, &m, &Progress::default()).unwrap();
assert_eq!(r.frames, 48_000, "one second of samples");
let bytes = std::fs::read(&m4a).unwrap();
let file = filmcraft_isobmff::open(bytes.clone()).unwrap();
assert_eq!(file.tracks.len(), 1, "audio only");
let t = &file.tracks[0];
let filmcraft_isobmff::CodecConfig::Aac(cfg) = &t.entries[0].codec else { panic!("not AAC: {:?}", t.entries[0].codec) };
assert_eq!((cfg.object_type, cfg.sample_rate, cfg.channel_config), (2, 48_000, 2));
assert!(t.samples.len() >= 48_000 / 1024, "{} access units", t.samples.len());
let mut dec = filmcraft_aac::Decoder::new(&cfg.asc).unwrap();
let mut pcm: Vec<Vec<f32>> = Vec::new();
for i in 0..t.samples.len() {
let out = dec.decode(&file.read_sample(&bytes, 0, i).unwrap()).unwrap();
pcm.resize(out.len(), Vec::new());
pcm.iter_mut().zip(out).for_each(|(a, b)| a.extend(b));
}
// the -6 dBFS tone (RMS about 0.35) survives the round trip
assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm));
if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &m4a) {
assert_eq!(j["streams"].as_array().unwrap().len(), 1);
let st = &j["streams"][0];
assert_eq!(st["codec_name"], "aac");
assert_eq!(st["sample_rate"], "48000");
assert_eq!(st["channels"], 2);
let d: f64 = j["format"]["duration"].as_str().unwrap().parse().unwrap();
assert!((d - 1.0).abs() < 0.03, "duration {d}");
}
// .aac: raw ADTS, mono 44.1 kHz at 128 kbps
let adts = dir.path("a.aac");
let mut s = ExportSettings { format: Format::Aac, path: adts.clone(), range, ..Default::default() };
s.audio = AudioSettings { sample_rate: Some(44_100), channels: 1, bitrate_kbps: 128, ..Default::default() };
export(&p, seq, &s, &m, &Progress::default()).unwrap();
let b = std::fs::read(&adts).unwrap();
// AAC-LC 44.1 kHz (index 4) mono: 00010 0100 0001 000
let mut dec = filmcraft_aac::Decoder::new(&[0x12, 0x08]).unwrap();
let (mut pos, mut units, mut pcm) = (0usize, 0usize, vec![Vec::new()]);
while pos < b.len() {
let h = &b[pos..pos + 7];
assert_eq!((h[0], h[1] & 0xF6), (0xFF, 0xF0), "ADTS sync at {pos}");
assert_eq!(h[1] & 0x01, 1, "no CRC");
assert_eq!(((h[2] >> 2) & 0x0F, ((h[2] & 1) << 2) | (h[3] >> 6)), (4, 1), "44.1 kHz mono");
let len = ((h[3] as usize & 0x03) << 11) | ((h[4] as usize) << 3) | (h[5] as usize >> 5);
assert!(len > 7 && pos + len <= b.len(), "frame length {len} at {pos}");
pcm[0].extend(dec.decode(&b[pos + 7..pos + len]).unwrap().swap_remove(0));
pos += len;
units += 1;
}
assert!(units >= 44_100 / 1024, "{units} frames");
assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm));
if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &adts) {
assert_eq!(j["format"]["format_name"], "aac");
let st = &j["streams"][0];
assert_eq!(st["codec_name"], "aac");
assert_eq!(st["sample_rate"], "44100");
assert_eq!(st["channels"], 1);
}
if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("AAC Audio decode") {
for f in [&m4a, &adts] {
let out = std::process::Command::new(&ffmpeg).args(["-v", "error", "-xerror", "-i", f, "-f", "null", "-"]).output().unwrap();
assert!(out.status.success() && out.stderr.is_empty(), "{f}: {}", String::from_utf8_lossy(&out.stderr));
}
}
}
#[test]
fn aac_audio_settings_and_names() {
assert_eq!(Format::from_name("m4a"), Some(Format::Aac));
assert_eq!(Format::from_name("AAC Audio"), Some(Format::Aac));
assert_eq!(Format::from_name(Format::Aac.id()), Some(Format::Aac));
assert!(Format::ALL.contains(&Format::Aac) && Format::Aac.is_audio_only() && !Format::H264.is_audio_only());
let s = ExportSettings { format: Format::Aac, include_audio: false, ..Default::default() };
let sum = s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(10 * TICKS_PER_SECOND));
assert_eq!(sum.video, "No video");
assert_eq!(sum.audio, "AAC, 320 kbps, 48000 Hz, Stereo");
assert_eq!(sum.estimated_bytes, 400_000, "320 kbps for 10 s, no picture");
let preset = presets::find_builtin("AAC Audio 48 kHz 320 kbps").unwrap();
assert_eq!(preset.settings.format, Format::Aac);
}
#[test]
fn wav_export_honours_explicit_sample_rates() {
let (project, sequence, sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
@ -701,7 +798,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() {
}
assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc));
assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)"));
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 19);
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 20);
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap();
assert_eq!(s.format, Format::Hevc);
assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc");

273
crates/media/src/dpx.rs Normal file
View File

@ -0,0 +1,273 @@
//! DPX (`.dpx`) stills, decoded clean-room from SMPTE ST 268 (Digital Picture Exchange).
//!
//! The first image element is decoded: luma, RGB, RGBA or ABGR, 8, 10, 12 or 16 bits per
//! component, big-endian (`SDPX`) or little-endian (`XPDS`) files. 10-bit components are filled into
//! 32-bit words (packing 1, method A: two padding bits at the bottom; packing 2, method B: at the
//! top), 12-bit components fill 16-bit words the same two ways. Scan lines start on a 32-bit
//! boundary and may carry end-of-line padding. Orientations 0–3 (either scan direction, either line
//! order) are honoured. Code values map linearly onto 8 bits (full range); a logarithmic (printing
//! density) transfer is shown as stored, without a film-log conversion.
//!
//! Not decoded (an "unsupported" error): run-length encoded elements, the 10/12-bit "packed"
//! layout (packing 0), Y'CbCr descriptors, 1-bit and floating-point components, transposed
//! orientations.
use crate::{MediaError, Result, still::Raster};
/// Generic file + image information header: everything we read lies below this.
const HEADER: usize = 1408;
/// First image element of the image information header (72 bytes each).
const ELEMENT: usize = 780;
/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for.
const MAX_PIXELS: usize = 1 << 28;
/// "Undefined" value of numeric header fields.
const UNDEFINED: u32 = u32::MAX;
fn err(name: &str, what: &str) -> MediaError {
MediaError::Decode(format!("{name}: DPX: {what}"))
}
fn unsupported(name: &str, what: String) -> MediaError {
MediaError::Unsupported(format!("{name}: DPX {what}"))
}
/// Whether `bytes` starts with the DPX magic number in either byte order.
pub fn sniff(bytes: &[u8]) -> bool {
bytes.starts_with(b"SDPX") || bytes.starts_with(b"XPDS")
}
/// Header and sample reads in the file's byte order.
#[derive(Clone, Copy)]
struct Order {
big: bool,
}
impl Order {
fn u16(self, b: &[u8], at: usize) -> u16 {
let v = [b.get(at).copied().unwrap_or(0), b.get(at + 1).copied().unwrap_or(0)];
if self.big { u16::from_be_bytes(v) } else { u16::from_le_bytes(v) }
}
fn u32(self, b: &[u8], at: usize) -> u32 {
let v = core::array::from_fn(|i| b.get(at + i).copied().unwrap_or(0));
if self.big { u32::from_be_bytes(v) } else { u32::from_le_bytes(v) }
}
}
/// How components are stored in a scan line.
#[derive(Clone, Copy)]
enum Packing {
/// One byte per component.
Bytes,
/// One 16-bit word per component; the value is `word >> shift` (12-bit method A: 4).
Words { shift: u32 },
/// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits.
Ten { pad: u32 },
}
impl Packing {
/// Bytes of a scan line holding `comps` components, before end-of-line padding (lines start on
/// a 32-bit boundary).
fn line_bytes(self, comps: usize) -> Option<usize> {
let raw = match self {
Packing::Bytes => comps,
Packing::Words { .. } => comps.checked_mul(2)?,
Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?,
};
raw.checked_next_multiple_of(4)
}
/// The `comps` component values of one scan line.
fn unpack(self, line: &[u8], comps: usize, order: Order, out: &mut Vec<u16>) {
out.clear();
match self {
Packing::Bytes => out.extend(line.iter().take(comps).map(|&b| u16::from(b))),
Packing::Words { shift } => out.extend((0..comps).map(|i| order.u16(line, i * 2) >> shift)),
Packing::Ten { pad } => {
for w in 0..comps.div_ceil(3) {
let word = order.u32(line, w * 4);
for s in [20 + pad, 10 + pad, pad] {
if out.len() < comps {
out.push(((word >> s) & 0x3FF) as u16);
}
}
}
}
}
}
}
/// Decode the first image element of a DPX file to straight-alpha RGBA8, top row first.
pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
let order = match bytes.get(..4) {
Some(b"SDPX") => Order { big: true },
Some(b"XPDS") => Order { big: false },
_ => return Err(err(name, "not a DPX file")),
};
if bytes.len() < HEADER {
return Err(err(name, "truncated header"));
}
let orientation = order.u16(bytes, 768);
let (w, h) = (order.u32(bytes, 772) as usize, order.u32(bytes, 776) as usize);
let el = ELEMENT;
let descriptor = bytes.get(el + 20).copied().unwrap_or(0);
let bits = bytes.get(el + 23).copied().unwrap_or(0);
let packing = order.u16(bytes, el + 24);
let encoding = order.u16(bytes, el + 26);
let element_offset = order.u32(bytes, el + 28);
let eol_padding = order.u32(bytes, el + 32);
if w == 0 || h == 0 {
return Err(err(name, "empty picture"));
}
let pixels = w.checked_mul(h).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?;
// component order per pixel: indices of R, G, B, A (None: opaque) in the stored components
let (n, map): (usize, [Option<usize>; 4]) = match descriptor {
6 => (1, [Some(0), Some(0), Some(0), None]),
50 => (3, [Some(0), Some(1), Some(2), None]),
51 => (4, [Some(0), Some(1), Some(2), Some(3)]),
52 => (4, [Some(3), Some(2), Some(1), Some(0)]),
d => return Err(unsupported(name, format!("descriptor {d}"))),
};
if encoding != 0 {
return Err(unsupported(name, "run-length encoding".into()));
}
let layout = match (bits, packing) {
(8, _) => Packing::Bytes,
(16, _) => Packing::Words { shift: 0 },
(10, 1) => Packing::Ten { pad: 2 },
(10, 2) => Packing::Ten { pad: 0 },
(12, 1) => Packing::Words { shift: 4 },
(12, 2) => Packing::Words { shift: 0 },
(b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))),
};
let (flip_x, flip_y) = match orientation {
0 => (false, false),
1 => (true, false),
2 => (false, true),
3 => (true, true),
o => return Err(unsupported(name, format!("orientation {o}"))),
};
let comps = w.checked_mul(n).ok_or_else(|| err(name, "picture too large"))?;
let pad = if eol_padding == UNDEFINED { 0 } else { eol_padding as usize };
let stride = layout.line_bytes(comps).and_then(|l| l.checked_add(pad)).ok_or_else(|| err(name, "picture too large"))?;
let offset = match element_offset {
0 | UNDEFINED => order.u32(bytes, 4) as usize,
o => o as usize,
};
// the last line needs no end-of-line padding
let size = stride.checked_mul(h - 1).and_then(|s| s.checked_add(stride - pad)).ok_or_else(|| err(name, "picture too large"))?;
let data = offset.checked_add(size).and_then(|end| bytes.get(offset..end)).ok_or_else(|| err(name, "truncated image data"))?;
let max = (1u32 << bits) - 1;
let to8 = |v: u16| ((u32::from(v).min(max) * 255 + max / 2) / max) as u8;
let mut rgba = vec![0u8; pixels * 4];
let mut line = Vec::with_capacity(comps);
for y in 0..h {
let src = data.get(y * stride..).unwrap_or_default();
layout.unpack(src, comps, order, &mut line);
let dy = if flip_y { h - 1 - y } else { y };
for x in 0..w {
let dx = if flip_x { w - 1 - x } else { x };
let o = (dy * w + dx) * 4;
let Some(px) = rgba.get_mut(o..o + 4) else { continue };
for (c, slot) in map.iter().enumerate() {
let v = match slot {
Some(i) => line.get(x * n + i).map_or(0, |&v| to8(v)),
None => 255,
};
if let Some(d) = px.get_mut(c) {
*d = v;
}
}
}
}
let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255);
Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha })
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal DPX file (header + `data`) in the given byte order.
fn file(big: bool, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, orientation: u16, data: &[u8]) -> Vec<u8> {
let mut f = vec![0u8; 2048];
let p32 = |f: &mut Vec<u8>, at: usize, v: u32| f[at..at + 4].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() });
let p16 = |f: &mut Vec<u8>, at: usize, v: u16| f[at..at + 2].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() });
f[0..4].copy_from_slice(if big { b"SDPX" } else { b"XPDS" });
p32(&mut f, 4, 2048);
p16(&mut f, 768, orientation);
p16(&mut f, 770, 1);
p32(&mut f, 772, w);
p32(&mut f, 776, h);
f[ELEMENT + 20] = descriptor;
f[ELEMENT + 23] = bits;
p16(&mut f, ELEMENT + 24, packing);
p32(&mut f, ELEMENT + 28, UNDEFINED);
p32(&mut f, ELEMENT + 32, UNDEFINED);
f.extend_from_slice(data);
f
}
#[test]
fn ten_bit_method_a_and_b() {
let word = |r: u32, g: u32, b: u32, pad: u32| ((r << (20 + pad)) | (g << (10 + pad)) | (b << pad)).to_be_bytes();
let mut data = Vec::new();
data.extend_from_slice(&word(1023, 0, 512, 2));
data.extend_from_slice(&word(4, 1019, 0, 2));
let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 1, 0, &data)).unwrap();
assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]);
assert!(!r.has_alpha);
let mut data = Vec::new();
data.extend_from_slice(&word(1023, 0, 512, 0));
data.extend_from_slice(&word(4, 1019, 0, 0));
let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 2, 0, &data)).unwrap();
assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]);
}
#[test]
fn rgba_components_roll_across_ten_bit_words() {
// 2 RGBA pixels = 8 components = 3 words; the last word is half empty
let comps = [1023u32, 0, 0, 1023, 0, 1023, 0, 0];
let mut data = Vec::new();
for c in comps.chunks(3) {
let get = |i: usize| c.get(i).copied().unwrap_or(0);
data.extend_from_slice(&((get(0) << 22) | (get(1) << 12) | (get(2) << 2)).to_le_bytes());
}
let r = decode("a.dpx", &file(false, 2, 1, 51, 10, 1, 0, &data)).unwrap();
assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]);
assert!(r.has_alpha);
}
#[test]
fn eight_bit_lines_pad_to_words_and_orientation_flips() {
// 1 × 2 RGB: each 3-byte line padded to 4; bottom-to-top, right-to-left orientation 3
let r = decode("a.dpx", &file(false, 1, 2, 50, 8, 0, 3, &[1, 2, 3, 0, 4, 5, 6, 0])).unwrap();
assert_eq!(r.rgba, [4, 5, 6, 255, 1, 2, 3, 255]);
// 2 × 1 luma, 16-bit big-endian; ABGR, 12-bit method A
let r = decode("a.dpx", &file(true, 2, 1, 6, 16, 0, 0, &[0xFF, 0xFF, 0x80, 0x00])).unwrap();
assert_eq!(r.rgba, [255, 255, 255, 255, 128, 128, 128, 255]);
let r = decode("a.dpx", &file(true, 1, 1, 52, 12, 1, 0, &[0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF0])).unwrap();
assert_eq!(r.rgba, [255, 0, 0, 128]);
}
#[test]
fn hostile_headers_fail_cleanly() {
// huge dimensions with no data, truncated data, unsupported layouts
assert!(decode("a.dpx", &file(true, u32::MAX, u32::MAX, 50, 10, 1, 0, &[])).is_err());
assert!(decode("a.dpx", &file(true, 4096, 4096, 50, 10, 1, 0, &[0; 64])).is_err());
assert!(decode("a.dpx", &file(true, 2, 2, 50, 10, 1, 0, &[0; 12])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 100, 8, 0, 0, &[0; 4])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 0, 0, &[0; 4])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 50, 32, 0, 0, &[0; 16])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 50, 8, 0, 5, &[0; 4])).is_err());
assert!(decode("a.dpx", &file(true, 0, 1, 50, 8, 0, 0, &[0; 4])).is_err());
assert!(decode("a.dpx", b"SDPX").is_err());
let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]);
f[4..8].copy_from_slice(&u32::MAX.to_be_bytes());
assert!(decode("a.dpx", &f).is_err());
let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]);
f[ELEMENT + 32..ELEMENT + 36].copy_from_slice(&(u32::MAX - 1).to_be_bytes());
assert!(decode("a.dpx", &file(true, 1, 2, 50, 8, 0, 0, &[0; 4])).is_err());
assert!(decode("a.dpx", &f).is_ok());
}
}

View File

@ -12,11 +12,13 @@
pub mod cache;
pub mod cancel;
pub mod digits;
pub mod dpx;
pub mod generators;
pub mod pending;
pub mod reader;
pub mod sequence;
pub mod still;
pub mod tga;
pub mod wav;
use std::path::Path;
@ -169,7 +171,7 @@ pub trait MediaSource: Send + Sync {
pub type SharedSource = Arc<dyn MediaSource>;
/// File extensions we recognise at import (the media browser filters on these).
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx"];
pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"];
pub const VIDEO_EXTENSIONS: &[&str] = &[
"mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv",
@ -193,7 +195,7 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result<Shar
if wav::sniff(&bytes) {
return Ok(Arc::new(wav::WavSource::parse(name, bytes)?));
}
if still::sniff(&bytes) {
if still::sniff(name, &bytes) {
return Ok(Arc::new(still::StillSource::decode(name, &bytes)?));
}
Err(MediaError::Unsupported(format!("{name}: unrecognised format")))

View File

@ -3,7 +3,7 @@
//! - [`Numbered`] splits a file name around its last run of digits; [`sequence_frames`] lists the
//! frames from the chosen file's number up to the highest number present (gaps are `None`).
//! - [`ImageSequenceSource`] decodes frames on demand through a [`FrameLoader`] (the host's file
//! access) with the still decoders (PNG, JPEG, TIFF, BMP, WebP, GIF), keeps recently used frames
//! access) with the still decoders (PNG, JPEG, TIFF, BMP, WebP, GIF, Targa, DPX), keeps recently used frames
//! in a byte-budgeted [`FrameCache`], and shows the nearest earlier frame for a missing or
//! undecodable one (the nearest later frame before the first).
//! - The frame rate is the caller's (Settings ▸ Media ▸ Indeterminate Media Timebase).
@ -134,9 +134,8 @@ impl ImageSequenceSource {
let bytes = loader(&first).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(format!("{first}: {e}")) } else { MediaError::Io(format!("{first}: {e}")) }
})?;
let img = image::load_from_memory(&bytes).map_err(|e| MediaError::Decode(format!("{first}: {e}")))?;
let has_alpha = img.color().has_alpha();
let (w, h) = (img.width(), img.height());
let img = crate::still::decode_raster(&first, &bytes)?;
let (w, h, has_alpha) = (img.width, img.height, img.has_alpha);
let ext = first.rsplit('.').next().unwrap_or("image").to_ascii_uppercase();
let n = frames.len() as i64;
let info = MediaInfo {
@ -161,7 +160,7 @@ impl ImageSequenceSource {
file_size: None,
};
let cache = FrameCache::new(256 << 20);
cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(VideoFrame::rgba8(w, h, img.to_rgba8().into_raw())));
cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(VideoFrame::rgba8(w, h, img.rgba)));
Ok(Self { info, frames, loader, cache })
}
@ -183,10 +182,10 @@ impl ImageSequenceSource {
if let Some(f) = self.cache.get(&i) {
return Ok(f);
}
let path = self.frames[i].as_deref().ok_or(MediaError::NoStream("video"))?;
let path = self.frames.get(i).and_then(|f| f.as_deref()).ok_or(MediaError::NoStream("video"))?;
let bytes = (self.loader)(path).map_err(|e| MediaError::Io(format!("{path}: {e}")))?;
let img = image::load_from_memory(&bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
let f = Arc::new(VideoFrame::rgba8(img.width(), img.height(), img.to_rgba8().into_raw()));
let img = crate::still::decode_raster(path, &bytes)?;
let f = Arc::new(VideoFrame::rgba8(img.width, img.height, img.rgba));
self.cache.insert(i, f.clone());
Ok(f)
}

View File

@ -1,4 +1,4 @@
//! Still images (PNG, JPEG, GIF (first frame), WebP, TIFF, BMP) as media sources.
//! Still images (PNG, JPEG, GIF (first frame), WebP, TIFF, BMP, Targa, DPX) as media sources.
use std::sync::Arc;
@ -10,8 +10,11 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result,
/// Default still duration (Premiere's preference default is 5 seconds).
pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
pub fn sniff(b: &[u8]) -> bool {
b.starts_with(b"\x89PNG")
/// Whether `b` (the contents of the file `name`) is a still image we decode.
pub fn sniff(name: &str, b: &[u8]) -> bool {
crate::dpx::sniff(b)
|| crate::tga::sniff(name, b)
|| b.starts_with(b"\x89PNG")
|| b.starts_with(&[0xFF, 0xD8, 0xFF])
|| b.starts_with(b"GIF8")
|| (b.len() > 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP")
@ -20,6 +23,31 @@ pub fn sniff(b: &[u8]) -> bool {
|| b.starts_with(b"BM")
}
/// A decoded picture: straight-alpha RGBA8, top row first.
pub struct Raster {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
/// Whether any pixel is not fully opaque.
pub has_alpha: bool,
}
/// Decode a still image file (`name` picks Targa, which has no magic number; the content picks
/// every other format).
pub fn decode_raster(name: &str, bytes: &[u8]) -> Result<Raster> {
if crate::dpx::sniff(bytes) {
return crate::dpx::decode(name, bytes);
}
if crate::tga::sniff(name, bytes) {
return crate::tga::decode(name, bytes);
}
let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?;
let has_alpha = img.color().has_alpha();
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
Ok(Raster { width, height, rgba: rgba.into_raw(), has_alpha })
}
pub struct StillSource {
info: MediaInfo,
frame: Arc<VideoFrame>,
@ -27,11 +55,8 @@ pub struct StillSource {
impl StillSource {
pub fn decode(name: &str, bytes: &[u8]) -> Result<Self> {
let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?;
let has_alpha = img.color().has_alpha();
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
Ok(Self::from_rgba(name, w, h, rgba.into_raw(), has_alpha, bytes.len() as u64))
let r = decode_raster(name, bytes)?;
Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64))
}
pub fn from_rgba(name: &str, w: u32, h: u32, rgba: Vec<u8>, has_alpha: bool, file_size: u64) -> Self {

299
crates/media/src/tga.rs Normal file
View File

@ -0,0 +1,299 @@
//! Targa (`.tga`) stills, decoded clean-room from the Truevision TGA File Format Specification 2.0.
//!
//! Image types 1/2/3 (colour-mapped, true-colour, grayscale) and their run-length encoded forms
//! 9/10/11; 15/16-bit (5-5-5, the top bit as alpha when the descriptor says one attribute bit),
//! 24-bit BGR and 32-bit BGRA true-colour, 8-bit grayscale and 16-bit grayscale + alpha, colour maps
//! of 15/16/24/32-bit entries indexed by 8 or 16 bits; any origin corner.
use crate::{MediaError, Result, still::Raster};
/// Length of the fixed header.
const HEADER: usize = 18;
/// TGA 2.0 files end with this signature (after two 32-bit area offsets).
const FOOTER: &[u8] = b"TRUEVISION-XFILE.\0";
/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for.
const MAX_PIXELS: usize = 1 << 28;
fn err(name: &str, what: &str) -> MediaError {
MediaError::Decode(format!("{name}: Targa: {what}"))
}
/// Whether `bytes` looks like a Targa file: a TGA 2.0 footer, or (for the original format, which
/// has no signature) a `.tga` name and a plausible header.
pub fn sniff(name: &str, bytes: &[u8]) -> bool {
let header_ok = || {
let Some(h) = bytes.first_chunk::<HEADER>() else { return false };
let (map_type, kind, depth) = (h[1], h[2], h[16]);
let (w, ht) = (u16::from_le_bytes([h[12], h[13]]), u16::from_le_bytes([h[14], h[15]]));
map_type <= 1 && matches!(kind, 1 | 2 | 3 | 9 | 10 | 11) && w > 0 && ht > 0 && matches!(depth, 8 | 15 | 16 | 24 | 32)
};
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
let named = matches!(ext.as_deref(), Some("tga" | "targa" | "icb" | "vda" | "vst"));
header_ok() && (named || bytes.ends_with(FOOTER))
}
/// Pixel layout of the stored values (after the colour-map lookup, for mapped images).
#[derive(Clone, Copy)]
enum Layout {
/// 15/16-bit `ARRRRRGG GGGBBBBB`, little-endian; `alpha`: the top bit is an alpha bit.
Rgb555 {
alpha: bool,
},
Bgr24,
Bgra32,
Gray8,
GrayAlpha16,
}
impl Layout {
fn true_colour(depth: u8, attr_bits: u8) -> Option<Self> {
Some(match depth {
15 => Layout::Rgb555 { alpha: false },
16 => Layout::Rgb555 { alpha: attr_bits == 1 },
24 => Layout::Bgr24,
32 => Layout::Bgra32,
_ => return None,
})
}
fn bytes(self) -> usize {
match self {
Layout::Rgb555 { .. } | Layout::GrayAlpha16 => 2,
Layout::Bgr24 => 3,
Layout::Bgra32 => 4,
Layout::Gray8 => 1,
}
}
/// RGBA of one stored value (`p` holds exactly [`bytes`](Self::bytes) bytes).
fn rgba(self, p: &[u8]) -> [u8; 4] {
let b = |i: usize| p.get(i).copied().unwrap_or(0);
match self {
Layout::Rgb555 { alpha } => {
let v = u16::from_le_bytes([b(0), b(1)]);
let five = |s: u16| {
let c = ((v >> s) & 31) as u8;
(c << 3) | (c >> 2)
};
let a = if !alpha || v & 0x8000 != 0 { 255 } else { 0 };
[five(10), five(5), five(0), a]
}
Layout::Bgr24 => [b(2), b(1), b(0), 255],
Layout::Bgra32 => [b(2), b(1), b(0), b(3)],
Layout::Gray8 => [b(0), b(0), b(0), 255],
Layout::GrayAlpha16 => [b(0), b(0), b(0), b(1)],
}
}
}
/// Decode a Targa file to straight-alpha RGBA8, top row first.
pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
let h = bytes.first_chunk::<HEADER>().ok_or_else(|| err(name, "truncated header"))?;
let (id_len, map_type, kind) = (usize::from(h[0]), h[1], h[2]);
let map_first = usize::from(u16::from_le_bytes([h[3], h[4]]));
let map_len = usize::from(u16::from_le_bytes([h[5], h[6]]));
let map_depth = h[7];
let (w, ht) = (usize::from(u16::from_le_bytes([h[12], h[13]])), usize::from(u16::from_le_bytes([h[14], h[15]])));
let (depth, descriptor) = (h[16], h[17]);
let attr_bits = descriptor & 0x0F;
let (right_to_left, top_to_bottom) = (descriptor & 0x10 != 0, descriptor & 0x20 != 0);
if w == 0 || ht == 0 {
return Err(err(name, "empty picture"));
}
let pixels = w.checked_mul(ht).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?;
let rle = kind >= 9;
// stored value layout and, for colour-mapped images, the palette
let mut palette: Vec<[u8; 4]> = Vec::new();
let map_entry_layout = if map_type == 1 {
Some(Layout::true_colour(map_depth, if map_depth == 16 { attr_bits.min(1) } else { 0 }).ok_or_else(|| err(name, "colour map entry size"))?)
} else {
None
};
let mut at = HEADER.checked_add(id_len).ok_or_else(|| err(name, "header"))?;
if let Some(l) = map_entry_layout {
let size = map_len.checked_mul(l.bytes()).ok_or_else(|| err(name, "colour map"))?;
let end = at.checked_add(size).ok_or_else(|| err(name, "colour map"))?;
let map = bytes.get(at..end).ok_or_else(|| err(name, "truncated colour map"))?;
palette = map.chunks_exact(l.bytes()).map(|e| l.rgba(e)).collect();
at = end;
}
let (layout, mapped) = match kind & 7 {
1 => {
if map_type != 1 || !matches!(depth, 8 | 16) {
return Err(err(name, "colour-mapped picture without an 8/16-bit index and a colour map"));
}
(if depth == 8 { Layout::Gray8 } else { Layout::GrayAlpha16 }, true)
}
2 => (Layout::true_colour(depth, attr_bits).ok_or_else(|| err(name, "true-colour pixel depth"))?, false),
3 => match depth {
8 => (Layout::Gray8, false),
16 => (Layout::GrayAlpha16, false),
_ => return Err(err(name, "grayscale pixel depth")),
},
_ => return Err(MediaError::Unsupported(format!("{name}: Targa image type {kind}"))),
};
let data = bytes.get(at..).unwrap_or_default();
let bpp = layout.bytes();
// the stored values, in file order
let stored: Vec<u8> = if rle {
unpack_rle(name, data, pixels, bpp)?
} else {
let n = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?;
data.get(..n).ok_or_else(|| err(name, "truncated pixel data"))?.to_vec()
};
let mut rgba = vec![0u8; pixels * 4];
for (i, v) in stored.chunks_exact(bpp).enumerate().take(pixels) {
let (x, y) = (i % w, i / w);
let x = if right_to_left { w - 1 - x } else { x };
let y = if top_to_bottom { y } else { ht - 1 - y };
let px = if mapped {
let byte = |i: usize| v.get(i).copied().unwrap_or(0);
let index = if bpp == 1 { usize::from(byte(0)) } else { usize::from(u16::from_le_bytes([byte(0), byte(1)])) };
*index.checked_sub(map_first).and_then(|k| palette.get(k)).ok_or_else(|| err(name, "colour index outside the colour map"))?
} else {
layout.rgba(v)
};
let o = (y * w + x) * 4;
if let Some(d) = rgba.get_mut(o..o + 4) {
d.copy_from_slice(&px);
}
}
// 32-bit files from writers that leave the attribute bits unset often store zero alpha
// everywhere: such a picture is opaque, not invisible
let alpha_used = match (layout, mapped) {
(Layout::Bgra32, false) => attr_bits != 0 || rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 0),
_ => true,
};
if !alpha_used {
rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255);
}
let has_alpha = rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255);
Ok(Raster { width: w as u32, height: ht as u32, rgba, has_alpha })
}
/// Expand run-length packets into `pixels` stored values of `bpp` bytes each. Packets may span
/// scan lines (the TGA 2.0 spec discourages it; older writers do it).
fn unpack_rle(name: &str, mut data: &[u8], pixels: usize, bpp: usize) -> Result<Vec<u8>> {
// a two-byte packet expands to at most 128 values: a header asking for more is lying
let bound = data.len().saturating_mul(64).saturating_add(128);
if pixels > bound {
return Err(err(name, "truncated run-length data"));
}
let total = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?;
let mut out = Vec::with_capacity(total);
while out.len() < total {
let (&head, rest) = data.split_first().ok_or_else(|| err(name, "truncated run-length data"))?;
let count = usize::from(head & 0x7F) + 1;
let want = (total - out.len()).min(count * bpp);
if head & 0x80 != 0 {
let v = rest.get(..bpp).ok_or_else(|| err(name, "truncated run-length data"))?;
for _ in 0..want / bpp {
out.extend_from_slice(v);
}
data = rest.get(bpp..).unwrap_or_default();
} else {
let n = count * bpp;
let v = rest.get(..n).ok_or_else(|| err(name, "truncated run-length data"))?;
out.extend_from_slice(v.get(..want).unwrap_or(v));
data = rest.get(n..).unwrap_or_default();
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec<u8> {
let mut b = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0];
b.extend_from_slice(&w.to_le_bytes());
b.extend_from_slice(&h.to_le_bytes());
b.extend_from_slice(&[depth, descriptor]);
b
}
#[test]
fn bottom_up_bgr_is_flipped() {
let mut f = header(2, 2, 2, 24, 0);
// bottom row first: blue, green; then top row: red, white
f.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]);
let r = decode("a.tga", &f).unwrap();
assert_eq!((r.width, r.height, r.has_alpha), (2, 2, false));
assert_eq!(r.rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]);
}
#[test]
fn rle_runs_and_raw_packets_cross_lines() {
let mut f = header(10, 3, 2, 32, 0x28);
// a run of 4 translucent red, then 2 raw pixels
f.extend_from_slice(&[0x83, 0, 0, 255, 128, 0x01, 1, 2, 3, 255, 4, 5, 6, 255]);
let r = decode("a.tga", &f).unwrap();
assert!(r.has_alpha);
assert_eq!(&r.rgba[..16], &[255, 0, 0, 128].repeat(4)[..]);
assert_eq!(&r.rgba[16..], &[3, 2, 1, 255, 6, 5, 4, 255]);
}
#[test]
fn colour_map_and_16_bit_alpha() {
let mut f = header(1, 2, 1, 8, 0x20);
f[1] = 1; // colour map present
f[3..5].copy_from_slice(&1u16.to_le_bytes()); // first entry index 1
f[5..7].copy_from_slice(&2u16.to_le_bytes());
f[7] = 24;
f.extend_from_slice(&[10, 20, 30, 40, 50, 60, 2, 1]);
let r = decode("a.tga", &f).unwrap();
assert_eq!(r.rgba, [60, 50, 40, 255, 30, 20, 10, 255]);
f[18 + 6] = 0; // index 0 is below the map's first entry
assert!(decode("a.tga", &f).is_err());
let mut f = header(2, 2, 1, 16, 0x21);
f.extend_from_slice(&0xFC00u16.to_le_bytes()); // opaque full red
f.extend_from_slice(&0x03E0u16.to_le_bytes()); // transparent full green
let r = decode("a.tga", &f).unwrap();
assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]);
}
#[test]
fn zero_alpha_32_bit_without_attribute_bits_is_opaque() {
let mut f = header(2, 1, 1, 32, 0);
f.extend_from_slice(&[1, 2, 3, 0]);
let r = decode("a.tga", &f).unwrap();
assert_eq!((r.rgba.as_slice(), r.has_alpha), (&[3, 2, 1, 255][..], false));
}
#[test]
fn hostile_headers_fail_without_allocating() {
// 65535 × 65535 RLE from a few bytes, truncated raw data, bad types
let mut f = header(10, 65535, 65535, 32, 0);
f.extend_from_slice(&[0xFF, 1, 2, 3, 4]);
assert!(decode("a.tga", &f).is_err());
let mut f = header(2, 4, 4, 24, 0);
f.extend_from_slice(&[0; 10]);
assert!(decode("a.tga", &f).is_err());
assert!(decode("a.tga", &header(2, 1, 1, 7, 0)).is_err());
assert!(decode("a.tga", &header(1, 1, 1, 8, 0)).is_err());
assert!(decode("a.tga", &[0; 5]).is_err());
for n in 0..40 {
let mut f = header(9, 3, 3, 8, 0);
f[1] = 1;
f[5] = 2;
f[7] = 16;
f.extend_from_slice(&[0x82, 1, 0, 1, 0x01, 0, 0]);
f.truncate(n);
let _ = decode("a.tga", &f);
}
}
#[test]
fn sniffs_by_footer_or_name() {
let mut f = header(2, 1, 1, 24, 0);
f.extend_from_slice(&[0, 0, 0]);
assert!(sniff("x.TGA", &f));
assert!(!sniff("x.bin", &f));
f.extend_from_slice(&[0; 8]);
f.extend_from_slice(FOOTER);
assert!(sniff("x.bin", &f));
assert!(!sniff("x.tga", b"\x89PNG\r\n\x1a\n0000000000"));
}
}

View File

@ -1,9 +1,12 @@
//! Effect masks (Premiere's per-effect masks: ellipse, 4-point polygon, free-draw Bézier).
//! Effect masks (Premiere's per-effect masks: ellipse, 4-point polygon or rounded rectangle,
//! free-draw Bézier).
//!
//! Every mask is a closed cubic Bézier path in **clip pixel space** (full-resolution source
//! pixels, the space of effect point parameters). An ellipse is four smooth vertices with circular
//! tangents and a polygon is vertices with zero-length tangents, so all three tools produce the same
//! [`MaskPath`] and the same rasterizer, keyframe interpolation and tracker apply to each.
//! tangents, a polygon is vertices with zero-length tangents and a rounded corner is a pair of
//! vertices joined by a circular-arc cubic ([`MaskPath::with_rounded_corners`]), so all the tools
//! produce the same [`MaskPath`] and the same rasterizer, keyframe interpolation and tracker apply
//! to each.
//!
//! A mask has a keyframable *Mask Path* (vertex-wise interpolation when the vertex counts match,
//! hold otherwise), *Feather* (falloff width in pixels, centred on the edge), *Opacity* (%),
@ -80,6 +83,86 @@ impl MaskPath {
Self::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)])
}
/// An axis-aligned rectangle with corners rounded by `radius` (Premiere 26's rounded
/// rectangle mask). `radius` ≤ 0 (or not finite) gives the plain 4-point [`MaskPath::rect`].
pub fn rounded_rect(x0: f64, y0: f64, x1: f64, y1: f64, radius: f64) -> Self {
let r = Self::rect(x0, y0, x1, y1);
if radius.is_finite() && radius > 0.0 { r.with_rounded_corners(radius, &[0, 1, 2, 3]) } else { r }
}
/// Indices of the sharp corners [`MaskPath::with_rounded_corners`] can round: corner vertices
/// of a closed path whose two segments are straight lines.
pub fn roundable_corners(&self) -> Vec<usize> {
let n = self.vertices.len();
if !self.closed || n < 3 {
return Vec::new();
}
(0..n)
.filter(|&i| {
let (Some(prev), Some(v), Some(next)) = (self.vertices.get((i + n - 1) % n), self.vertices.get(i), self.vertices.get((i + 1) % n)) else {
return false;
};
v.is_corner() && is_zero(&prev.t_out) && is_zero(&next.t_in)
})
.collect()
}
/// The path with each listed corner replaced by a circular fillet of `radius`: two vertices on
/// the adjacent edges joined by a cubic arc (handles `4/3·tan(φ/4)·r` for a turn of φ, so a
/// right angle gets the [`KAPPA`] quarter circle). The fillet is shrunk so it never takes more
/// than half of either edge. Every listed roundable corner ([`MaskPath::roundable_corners`])
/// becomes two vertices, coincident when its geometry is degenerate (a straight or reversing
/// corner, a zero-length edge), so applying the same list to each keyframe of an animated path
/// keeps the vertex counts equal. Other indices (out of range, curved, repeated) are left alone.
pub fn with_rounded_corners(&self, radius: f64, corners: &[usize]) -> Self {
let n = self.vertices.len();
let mut round = vec![false; n];
for &i in corners {
if let Some(r) = round.get_mut(i) {
*r = true;
}
}
let roundable = self.roundable_corners();
round.iter_mut().enumerate().for_each(|(i, r)| *r &= roundable.binary_search(&i).is_ok());
let radius = if radius.is_finite() { radius.clamp(0.0, 1e7) } else { 0.0 };
let mut vertices = Vec::with_capacity(n.saturating_mul(2));
for (i, v) in self.vertices.iter().enumerate() {
if !round.get(i).copied().unwrap_or(false) {
vertices.push(*v);
continue;
}
let (Some(prev), Some(next)) = (self.vertices.get((i + n - 1) % n), self.vertices.get((i + 1) % n)) else {
vertices.push(*v);
continue;
};
let (a, b) = (v.p - prev.p, next.p - v.p);
let (la, lb) = (a.length(), b.length());
let fillet = (la > 1e-9 && lb > 1e-9 && radius > 0.0).then(|| (a * (1.0 / la), b * (1.0 / lb))).and_then(|(u, w)| {
// turn angle φ between the incoming and outgoing directions
let phi = (u.x * w.x + u.y * w.y).clamp(-1.0, 1.0).acos();
if !(1e-6..std::f64::consts::PI - 1e-6).contains(&phi) {
return None;
}
let tan_half = (phi / 2.0).tan();
let d = (radius * tan_half).min(la / 2.0).min(lb / 2.0);
let r = d / tan_half;
let h = 4.0 / 3.0 * (phi / 4.0).tan() * r;
Some((u, w, d, h))
});
match fillet {
Some((u, w, d, h)) => {
vertices.push(MaskVertex { p: v.p - u * d, t_in: Vec2::ZERO, t_out: u * h });
vertices.push(MaskVertex { p: v.p + w * d, t_in: w * -h, t_out: Vec2::ZERO });
}
None => {
vertices.push(MaskVertex::corner(v.p));
vertices.push(MaskVertex::corner(v.p));
}
}
}
Self { vertices, closed: self.closed }
}
pub fn len(&self) -> usize {
self.vertices.len()
}
@ -439,4 +522,59 @@ mod tests {
let back: Mask = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
}
#[test]
fn rounded_rect_corners_are_quarter_circles() {
let p = MaskPath::rounded_rect(0.0, 0.0, 100.0, 60.0, 20.0);
assert_eq!(p.len(), 8);
// top-left corner: a quarter circle of radius 20 about (20, 20); the edges stay straight
let pts = p.flatten(0.01);
let mut on_arc = 0;
for q in &pts {
let (cx, cy) = (q.x.clamp(20.0, 80.0), q.y.clamp(20.0, 40.0));
let d = ((q.x - cx).powi(2) + (q.y - cy).powi(2)).sqrt();
if (q.x - cx).abs() > 1e-9 && (q.y - cy).abs() > 1e-9 {
on_arc += 1;
assert!((d - 20.0).abs() < 0.03, "{q:?} is {d} from its corner centre");
}
assert!(q.x >= -1e-9 && q.x <= 100.0 + 1e-9 && q.y >= -1e-9 && q.y <= 60.0 + 1e-9, "{q:?} outside the rectangle");
}
assert!(on_arc >= 8, "the corners are curved: {on_arc} arc points");
// the sharp corner itself is cut away
assert!(pts.iter().all(|q| q.x + q.y > 5.0), "the corner at (0, 0) is rounded off");
// radius 0 is the plain rectangle
assert_eq!(MaskPath::rounded_rect(0.0, 0.0, 10.0, 5.0, 0.0), MaskPath::rect(0.0, 0.0, 10.0, 5.0));
}
#[test]
fn rounding_clamps_to_half_an_edge_and_ignores_curves_and_hostile_input() {
// a radius larger than the shape gives a stadium: fillets meet at the edges' midpoints
let p = MaskPath::rounded_rect(0.0, 0.0, 100.0, 40.0, 1e9);
assert_eq!(p.len(), 8);
for v in &p.vertices {
assert!(v.p.x.is_finite() && v.p.y.is_finite());
}
assert!(p.vertices.iter().any(|v| (v.p - Vec2::new(0.0, 20.0)).length() < 1e-9), "{:?}", p.vertices);
// a triangle: the 60° turns get fillets tangent to both edges, within the triangle
let tri = MaskPath::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(50.0, 86.6)]);
let r = tri.with_rounded_corners(10.0, &tri.roundable_corners());
assert_eq!(r.len(), 6);
assert!(r.vertices.iter().all(|v| v.p.y >= -1e-9));
// an ellipse has no sharp corners; NaN / negative radii and bad indices change nothing harmful
let e = MaskPath::ellipse(Vec2::new(50.0, 50.0), Vec2::new(30.0, 20.0));
assert!(e.roundable_corners().is_empty());
assert_eq!(e.with_rounded_corners(10.0, &[0, 1, 2, 3]), e);
let sq = MaskPath::rect(0.0, 0.0, 10.0, 10.0);
assert_eq!(sq.with_rounded_corners(5.0, &[usize::MAX, 99]), sq);
for bad in [f64::NAN, f64::INFINITY, -3.0, 0.0] {
let q = sq.with_rounded_corners(bad, &[0]);
assert_eq!(q.len(), 5, "a listed corner always splits in two");
assert!(q.vertices.iter().all(|v| v.is_corner() && v.p.x.is_finite()));
}
// a collinear vertex splits into two coincident corners, keeping vertex counts stable
let line = MaskPath::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(5.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(5.0, 5.0)]);
let q = line.with_rounded_corners(2.0, &[1]);
assert_eq!(q.len(), 5);
assert_eq!(q.vertices[1].p, q.vertices[2].p);
}
}

View File

@ -2974,6 +2974,9 @@
Step Forward 1 Frame Avanzar 1 fotograma
Match Playhead Igualar cabezal
Gang to Program Vincular al programa
AAC Audio AAC Audio
AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps
MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), estéreo, AAC-LC de 48 kHz a 320 kbps
AIFF AIFF
AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits
AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -2976,6 +2976,9 @@
Step Forward 1 Frame Avancer d'une image
Match Playhead Faire correspondre la tête de lecture
Gang to Program Coupler au moniteur programme
AAC Audio AAC Audio
AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps
MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), AAC-LC stéréo 48 kHz à 320 kbps
AIFF AIFF
AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits
AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stéréo 48 kHz 16 bits

Can't render this file because it contains an unexpected character in line 5 and column 83.

View File

@ -2963,6 +2963,9 @@
Step Forward 1 Frame 1 フレーム進む
Match Playhead 再生ヘッドに合わせる
Gang to Program プログラムと連動
AAC Audio AAC Audio
AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps
MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 オーディオ (.m4a)、ステレオ 48 kHz AAC-LC 320 kbps
AIFF AIFF
AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit
AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -2957,6 +2957,9 @@
Step Forward 1 Frame 前进 1 帧
Match Playhead 匹配播放头
Gang to Program 联动到节目
AAC Audio AAC 音频
AAC Audio 48 kHz 320 kbps AAC 音频 48 kHz 320 kbps
MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 音频 (.m4a),立体声 48 kHz AAC-LC,320 kbps
AIFF AIFF
AIFF 48 kHz 16-bit AIFF 48 kHz 16 位
AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -1411,6 +1411,7 @@ impl FilmcraftApp {
}
// Clear in the Project panel removes the selected bin when no item is selected (#587).
let clear = if self.ui.focused == PanelKind::Project && id == "project.delete" {
self.ui.project_panel.rename = None;
crate::panels::project_views::clear_params(&self.session.state.project_selection, self.ui.project_panel.selected_bin)
} else {
None

View File

@ -287,8 +287,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
// Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus.
let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() });
app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture");
if show_picture {
monitor_view::pan_input(app, ui, which, video_area, pic);
// (Comparison View always fits both frames)
if show_picture && !compare {
monitor_view::zoom_pan_input(app, ui, which, video_area, pic, display_w, ppp);
}
if which == Which::Program && show_picture {
eyedropper(app, ui, &pic_resp, pic, target, rate, time);

View File

@ -1,7 +1,8 @@
//! Monitor view options (the View menu and the monitors' wrench menus): Playback / Paused
//! Resolution, High Quality Playback, Display Mode (Composite Video, Alpha, Red, Green, Blue,
//! Multi-Camera, Audio Waveform, Comparison View, Video and Audio Waveform Split), Magnification
//! (Fit, 10%…1600%, scroll or Hand-tool panning), rulers, guides (dragged out of the rulers, Add
//! (Fit, 10%…1600%, the wheel zooming about the pointer, middle-button or Hand-tool panning),
//! rulers, guides (dragged out of the rulers, Add
//! Guide…, Clear Guides, Lock Guides, templates saved in the user preferences) and Snap in Program
//! Monitor (graphic drags snap to guides, the frame edges and centre).
//!
@ -354,14 +355,42 @@ pub fn picture_rect(area: Rect, fw: f32, fh: f32, v: &MonitorView, ppp: f32) ->
}
}
/// Pan a magnified picture: scroll over it, or drag with the Hand tool.
pub fn pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, pic: Rect) {
/// Zoom and pan the picture `pic` shown in `area`: the wheel (or a pinch) zooms about the pointer,
/// from Fit too, between the zoom menu's 10% and 1600%; a middle-button drag, or a drag with the
/// Hand tool, pans a magnified picture. `fw` is the picture's width in frame pixels.
pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, pic: Rect, fw: f32, ppp: f32) {
let ctx = ui.ctx().clone();
if ui.rect_contains_pointer(area)
&& let Some(p) = ctx.pointer_hover_pos()
{
// as fast as the Timeline's own wheel zoom
let wheel = crate::panels::timeline::wheel_input(&ctx);
let f = if (wheel.pinch - 1.0).abs() > 1e-4 { wheel.pinch } else { (1.0 + wheel.delta.y * 0.01).clamp(0.5, 2.0) };
if (f - 1.0).abs() > 1e-4 && pic.width() > 0.0 && fw > 0.0 {
let cur = pic.width() * ppp / fw;
let z = (cur * f).clamp(ZOOMS[0].1, ZOOMS[ZOOMS.len() - 1].1);
let k = z / cur;
// the point of the picture under the pointer stays under it
let centre = p - (p - pic.center()) * k;
let max = ((pic.size() * k - area.size()) / 2.0).max(Vec2::ZERO);
let v = view_mut(app, w);
v.zoom = Some(z);
v.pan = [(centre.x - area.center().x).clamp(-max.x, max.x), (centre.y - area.center().y).clamp(-max.y, max.y)];
return;
}
}
if view(app, w).zoom.is_none() {
return;
}
let mut d = Vec2::ZERO;
if ui.rect_contains_pointer(area) {
d += ui.input(|i| i.smooth_scroll_delta);
// a middle-button drag that began over the picture pans it, whatever the tool
let held = egui::Id::new((prefix(w), "middle-pan"));
let (pressed, down, moved) = ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Middle), i.pointer.middle_down(), i.pointer.delta()));
let panning = down && (ui.data(|m| m.get_temp::<bool>(held)).unwrap_or(false) || (pressed && ui.rect_contains_pointer(area)));
ui.data_mut(|m| m.insert_temp(held, panning));
if panning {
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
d += moved;
}
if app.ui.tool == Tool::Hand {
let resp = ui.interact(area, egui::Id::new((prefix(w), "pan")), Sense::drag());

View File

@ -625,7 +625,10 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action
actions.push(("file.newBin".into(), json!({"parent": parent, "panel": pre})));
}
"project.delete" => match crate::panels::project_views::clear_params(&app.session.state.project_selection, app.ui.project_panel.selected_bin) {
Some(p) => actions.push((id.into(), p)),
Some(p) => {
app.ui.project_panel.rename = None;
actions.push((id.into(), p));
}
None => app.ui.status = tl!("Select items to clear").into(),
},
_ => actions.push((id.into(), json!({}))),
@ -732,6 +735,7 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool {
actions.push(("projectPanel.rename".into(), json!({})));
}
if item(ui, "delete", tl!("Delete"), clear.is_some(), Some("Backspace")) {
app.ui.project_panel.rename = None;
actions.push(("project.delete".into(), clear.unwrap_or_default()));
}
ui.separator();
@ -979,6 +983,9 @@ pub fn new_bin(app: &mut FilmcraftApp, params: &Value) -> Result<Value, String>
app.ui.expanded_bins.push(p);
}
let panel = params.get("panel").and_then(Value::as_str).map_or(shown.prefix, str::to_string);
// Clicking a bin already clears the item selection so Clear targets the bin (#587). New Bin
// must do the same: leftover clips would otherwise steal the trash button and Delete.
let _ = app.session.execute("project.select", json!({"items": []}));
app.ui.project_panel.selected_bin = Some(bin);
app.ui.project_panel.rename = Some(Rename { item: None, bin: Some(bin), text, panel });
Ok(r)

View File

@ -1902,11 +1902,11 @@ fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem
}
/// What the wheel and the trackpad did this frame.
struct WheelInput {
pub(crate) struct WheelInput {
/// Scroll in points (wheel lines and pages converted), positive = content moves right / down.
delta: egui::Vec2,
pub(crate) delta: egui::Vec2,
/// Pinch factor (1 = none).
pinch: f32,
pub(crate) pinch: f32,
command: bool,
alt: bool,
shift: bool,
@ -1915,7 +1915,7 @@ struct WheelInput {
/// The wheel events of this frame, read from the events themselves: egui turns Cmd + wheel into a
/// zoom and Shift + wheel into a sideways scroll before anyone asks, and the Timeline has its own
/// meaning for both. The modifiers are those of the events (a script's wheel carries its own).
fn wheel_input(ctx: &egui::Context) -> WheelInput {
pub(crate) fn wheel_input(ctx: &egui::Context) -> WheelInput {
ctx.input(|i| {
let mut w = WheelInput { delta: egui::Vec2::ZERO, pinch: 1.0, command: false, alt: false, shift: false };
for e in &i.events {

View File

@ -570,6 +570,29 @@ fn new_bin_asks_for_its_name() {
assert!(d.app().ui.project_panel.rename.is_none());
}
#[test]
fn new_bin_then_clear_removes_the_bin() {
// leftover Project item selection must not keep Clear from removing the new bin
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
d.click("project.button.new-item");
d.wait_for("project.newItem.file.newColorMatte");
d.click("project.newItem.file.newColorMatte");
d.click("colorMatte.ok");
let items0 = d.app().session.project.items.len();
let bins0 = d.app().session.project.root.children.len();
assert!(!d.app().session.state.project_selection.is_empty());
d.click("project.button.file.newBin");
d.frames(2);
let bin = d.app().ui.project_panel.selected_bin.expect("the new bin is selected");
assert!(d.app().session.state.project_selection.is_empty(), "New Bin clears leftover item selection");
d.click("project.button.project.delete");
d.frames(2);
assert!(d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).is_none(), "Clear removes the new bin");
assert_eq!(d.app().session.project.root.children.len(), bins0);
assert_eq!(d.app().session.project.items.len(), items0, "clips already in the project stay");
}
// ------------------------------------------------------------------------------------ Media Browser
fn media_dir(tag: &str) -> std::path::PathBuf {

View File

@ -183,6 +183,64 @@ fn view_menu_resolutions_and_display_modes() {
assert_eq!((s["display"].as_str(), s["show_rulers"].as_bool()), (Some("Composite"), Some(true)));
}
fn num(v: &Value) -> f64 {
v.as_f64().unwrap_or_else(|| panic!("not a number: {v}"))
}
/// #807: the wheel over a monitor zooms about the pointer, a middle-button drag pans, Fit resets,
/// and each monitor keeps its own view.
#[test]
fn wheel_zooms_about_the_pointer_and_the_middle_button_pans() {
let mut d = Driver::demo();
// at 100% the picture is larger than the monitor, so the element is the monitor's area
d.menu("view.magnification.100");
let area = d.rect("program.picture");
let centre = (area[0] + area[2] / 2.0, area[1] + area[3] / 2.0);
let at = (area[0] + area[2] * 0.25, area[1] + area[3] * 0.4);
// 50 points up: 1.5 times; the point under the pointer stays under it
d.ok("ui.scroll", json!({"x": at.0, "y": at.1, "dx": 0.0, "dy": 50.0}));
d.frames(2);
let p = d.program();
assert!((num(&p["zoom"]) - 1.5).abs() < 1e-4, "{p}");
let want = ((centre.0 - at.0) * 0.5, (centre.1 - at.1) * 0.5);
assert!((num(&p["pan"][0]) - want.0).abs() < 1.0 && (num(&p["pan"][1]) - want.1).abs() < 1.0, "pan {} for {want:?}", p["pan"]);
// the middle button drags the picture, whatever the tool
let (zoom, pan) = (num(&p["zoom"]), [num(&p["pan"][0]), num(&p["pan"][1])]);
let send = |d: &mut Driver, e: egui::Event| {
d.harness.input_mut().events.push(e);
d.frames(1);
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() };
let from = egui::pos2(centre.0 as f32, centre.1 as f32);
send(&mut d, egui::Event::PointerMoved(from));
send(&mut d, button(from, true));
for k in 1..=4 {
send(&mut d, egui::Event::PointerMoved(from + egui::vec2(-10.0 * k as f32, 5.0 * k as f32)));
}
send(&mut d, button(from + egui::vec2(-40.0, 20.0), false));
let p = d.program();
assert_eq!(num(&p["zoom"]), zoom, "a pan does not zoom");
assert!((num(&p["pan"][0]) - (pan[0] - 40.0)).abs() < 1.0 && (num(&p["pan"][1]) - (pan[1] + 20.0)).abs() < 1.0, "pan {} after {pan:?}", p["pan"]);
// zooming out stops at the menu's smallest level; Fit resets zoom and pan
for _ in 0..12 {
d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": -50.0}));
}
d.frames(2);
assert!((num(&d.program()["zoom"]) - 0.1).abs() < 1e-6, "stops at 10%");
d.menu("view.magnification.fit");
let p = d.program();
assert!(p["zoom"].is_null() && p["pan"] == json!([0.0, 0.0]), "{p}");
// from Fit, the wheel zooms in too; the Source monitor keeps its own view
d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": 20.0}));
d.frames(2);
assert!(d.program()["zoom"].as_f64().is_some(), "zoomed in from Fit");
let source = d.ok("ui.inspect", json!({}))["ui"]["source"].clone();
assert!(source["zoom"].is_null() && source["pan"] == json!([0.0, 0.0]), "{source}");
}
#[test]
fn magnification_pans_and_guides_from_rulers() {
let mut d = Driver::demo();

View File

@ -530,7 +530,8 @@ with different vertex counts hold). Feather, Opacity and Expansion are ordinary
- **GPU.** `filmcraft-gpu::GpuMask` evaluates the same coverage and mix in WGSL (compute), tested
to agree with the CPU within 3·10⁻⁶. Layers with masks are CPU-rendered images in frame plans.
- **Editing.** `masks.*` commands (add / remove / set / moveVertex / translate / addVertex /
removeVertex / toggleVertexSmooth / select / list); keyframe commands take `"mask": n`. The
removeVertex / toggleVertexSmooth / roundCorners / select / list; `masks.add` takes a
`cornerRadius` for a rounded rectangle); keyframe commands take `"mask": n`. The
Program monitor overlay drags vertices, Bézier handles, the whole mask and the feather /
expansion handles; drags merge into one undo step.
- **Tracking** (`render::track`, `masks.track`): Shi–Tomasi features inside the mask, pyramidal
@ -665,6 +666,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
| `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV |
| `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) |
| `mjpeg` | built in | MOV |
| `aac` | `filmcraft-aac` (AAC-LC, the audio settings' bitrate) | audio only: MPEG-4 audio `.m4a` (`isobmff`, priming in an edit list), or raw ADTS when the path ends in `.aac` |
| `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) |
| `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`<stem>_A1.mxf` …, `Report::extra_files`) |
| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |

View File

@ -56,8 +56,8 @@ only). "Tested" names the evidence.
| WebP | — | yes | no | ahead |
| **PSD** (layers as sequence) | read (layered) | no | — | 0% |
| **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% |
| **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% |
| Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% |
| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); no packed 10/12-bit, RLE or Y'CbCr | sequences (10-bit RGB, BT.709; #779) | 80% |
| Targa | read + write | yes: every image type, RLE, colour maps, 15–32-bit (#821) | sequences (24-bit, 32-bit with alpha; #779) | 95% |
| HEIF / HEIC | read | no | — | 0% |
| Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% |
@ -68,7 +68,7 @@ only). "Tested" names the evidence.
| WAV / BWF (timecode, iXML partial) | read + write | yes | yes | 90% |
| AIFF / AIFC | read + write | yes | yes | 95% |
| MP3 | read + write | read | **no** | 60% |
| AAC / M4A | read + write | yes | inside MP4 / MOV only, **no AAC-only file** | 70% |
| AAC / M4A | read + write | yes | yes (in MP4 / MOV, and AAC Audio: `.m4a` or ADTS `.aac`, #814) | 95% |
| FLAC, Ogg, Opus | partial | yes | no | ahead on read |
| WMA | read (Windows) | no | no | 0% |
@ -84,7 +84,7 @@ only). "Tested" names the evidence.
## Summary
**File formats dimension: ~60%, 140–240 h** (codecs counted separately). The beta blocker is
`.prproj`. After it, by user impact: AVI and WMV import, EXR / DPX / PSD stills, AAF / OMF
`.prproj`. After it, by user impact: AVI and WMV import, EXR / PSD stills, AAF / OMF
validation in Avid and Pro Tools, DVD / Blu-ray / DCP / broadcast MXF delivery.
## Revision history

View File

@ -165,9 +165,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
5.1 core), AVI (#598; listed as an extension but no
demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D
incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 /
JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS;
transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV
multi-audio (#601).
JPEG XS MXF, Cinema DNG; stills: PSD, EXR, HEIF / HEIC, Radiance HDR, AI / EPS, and the rarer
DPX layouts (packed 10/12-bit, run-length, Y'CbCr; Targa and DPX stills and sequences import
since #821, deeper than 8 bits kept at 8); transparent video (#402); Sony start timecode in
`rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601).
- **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use:
clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md),
[file-format-parity.md](file-format-parity.md).
@ -191,11 +192,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
## G13. Delivery formats
- **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF,
AS-10 / AS-11, XDCAM HD MXF, P2, compressed (ZIP / PIZ / DWA) OpenEXR, MP3 and AAC-only audio,
ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding,
smart render, HEVC software encoder (HEVC exists only where a hardware encoder does).
AS-10 / AS-11, XDCAM HD MXF, P2, compressed (ZIP / PIZ / DWA) OpenEXR, MP3 audio, interlaced
encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does).
- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); OpenEXR
sequences (half float, linear, uncompressed; #806).
sequences (half float, linear, uncompressed; #806); AAC Audio (audio-only `.m4a`, or ADTS
`.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342).
- **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md).
## G14. Localization: 8 of the 12 key languages missing
@ -234,11 +235,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking
(and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles,
redesigned 26.0 mask tools (rounded corners, constrained lines).
redesigned 26.0 mask tools (constrained lines; an on-monitor corner-roundness handle).
- **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense
coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow
instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key
Setting applied as a parameter preset (#458).
Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners
with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe;
#813).
- **Estimate:** 60–110 h.
## G19. Speech to text off in release builds

View File

@ -15,7 +15,7 @@ focused monitor (Program by default). Code: `crates/ui-egui/src/panels/monitor_v
| `view.display.multicam` | Display Mode ▸ Multi-Camera | the Program's multi-camera view (`multicam.toggleView`) |
| `view.display.audioWaveform`, `view.display.videoAndWaveform` | Display Mode ▸ Audio Waveform / Video and Audio Waveform Split | Source Monitor: the clip's waveform (click to move the source playhead); audio-only clips always show it |
| `view.display.comparison` | Display Mode ▸ Comparison View | Program: the reference frame (left) beside the current frame; the reference starts at the playhead and is stepped or reset with the buttons under it, or `view.compare.setReference {time\|seconds}` |
| `view.magnification.<fit\|10\|25\|50\|75\|100\|150\|200\|400\|800\|1600>` | View ▸ Magnification and the zoom dropdown | 100% = one frame pixel per screen pixel; scroll or drag with the Hand tool to pan |
| `view.magnification.<fit\|10\|25\|50\|75\|100\|150\|200\|400\|800\|1600>` | View ▸ Magnification and the zoom dropdown | 100% = one frame pixel per screen pixel. The mouse wheel (or a pinch) over a monitor zooms about the pointer, from Fit too, within 10%–1600%; drag with the middle mouse button, or with the Hand tool, to pan; Fit resets both. Each monitor keeps its own zoom and pan |
| `view.showRulers`, `view.showGuides`, `view.lockGuides`, `view.clearGuides` | View | toggles take `{"enabled": bool}` |
| `view.addGuide` | View ▸ Add Guide… | dialog, or `{"orientation": "vertical\|horizontal", "position": px}` |
| `view.snapInProgramMonitor` | View ▸ Snap in Program Monitor | graphic moves snap to the frame edges / centre and the guides (6 pt) |

View File

@ -50,6 +50,7 @@ signing jobs are refused, and the draft-release job, which needs them, is skippe
| Windows on ARM64 | `filmcraft-<v>-windows-arm64.msi`, `filmcraft-<v>-windows-arm64-portable.zip` | `windows-latest` (cross-compiled) |
| Linux x86_64 | `filmcraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `filmcraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
| Linux riscv64 | `filmcraft-<v>-linux-riscv64.tar.gz` | `ubuntu-24.04`, cross-compiled (glibc 2.39+; CLI smoke-tested under QEMU) |
| AppImage updates | `filmcraft-<v>-linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage |
| Flatpak | `filmcraft-<v>-linux-x86_64.flatpak`, `filmcraft-<v>-linux-aarch64.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackaged Linux tarball) |
| FreeBSD 14 x86_64 | `filmcraft-<v>-freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM on `ubuntu-latest` |

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Build and package FilmCraft for Linux (<arch> is x86_64 or aarch64):
# Build and package FilmCraft for Linux (<arch> is x86_64 or aarch64, or riscv64 when cross-compiling):
#
# $DIST/filmcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/filmcraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake)
@ -13,6 +13,10 @@
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli,
# zsyncmake (the zsync package) for the AppImage's .zsync.
#
# Cross-compiling (CI's riscv64 job, tar only): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust
# target), CROSS_COMPILE (binutils prefix, for strip) and optionally EMULATOR (qemu-riscv64) to
# smoke-test the CLI.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
@ -25,13 +29,14 @@ while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
-h | --help) sed -n '2,21p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
ARCH="$(uname -m)"
ARCH="${CROSS_ARCH:-$(uname -m)}"
case "$ARCH" in
riscv64) DEB_ARCH=riscv64 ;;
x86_64) DEB_ARCH=amd64 ;;
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
@ -42,9 +47,13 @@ BASENAME="filmcraft-$VERSION-linux-$ARCH"
echo "==> FilmCraft $VERSION for Linux $ARCH ($FORMATS)"
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli)
if [ -n "${CROSS_TARGET:-}" ]; then
(cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli --target "$CROSS_TARGET")
else
(cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli)
fi
fi
BIN="$CARGO_TARGET_DIR/release"
BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
WORK="$CARGO_TARGET_DIR/linux-package"
STAGE="$WORK/root"
rm -rf "$WORK"
@ -52,7 +61,7 @@ rm -rf "$WORK"
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
install -Dm755 "$BIN/filmcraft" "$STAGE/usr/bin/filmcraft"
install -Dm755 "$BIN/filmcraft-cli" "$STAGE/usr/bin/filmcraft-cli"
strip "$STAGE/usr/bin/filmcraft" "$STAGE/usr/bin/filmcraft-cli" 2>/dev/null || true
"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/filmcraft" "$STAGE/usr/bin/filmcraft-cli" 2>/dev/null || true
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
mkdir -p "$STAGE/usr/share/metainfo"
@ -136,6 +145,10 @@ if has appimage; then
fi
fi
"$STAGE/usr/bin/filmcraft-cli" --version
if [ -z "${CROSS_TARGET:-}" ]; then
"$STAGE/usr/bin/filmcraft-cli" --version
elif [ -n "${EMULATOR:-}" ]; then
"$EMULATOR" "$STAGE/usr/bin/filmcraft-cli" --version
fi
echo "==> done"
ls -lh "$DIST"