Merge remote-tracking branch 'upstream/main' into HEAD
This commit is contained in:
commit
b74202696d
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>` … |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
21
docs/gaps.md
21
docs/gaps.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue