mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Merge remote-tracking branch 'upstream/main' into docking-frame-trial
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,74 @@
|
||||
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, layers, assets,
|
||||
# wasm) on every pull request and every push to main. Today only path-filtered workflows run on
|
||||
# pull requests, so most PRs land with no test run at all.
|
||||
#
|
||||
# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml, so the font tests see what a release build embeds.
|
||||
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: cargo xtask ci
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUST_BACKTRACE: "1"
|
||||
# `xtask ci` builds clippy and the tests with --release. The release profile (thin LTO, one
|
||||
# codegen unit) is for shipped binaries: on a 4-core runner it makes linking the ~220 test
|
||||
# binaries take over an hour. CI keeps the optimisation level but builds in parallel units.
|
||||
CARGO_PROFILE_RELEASE_LTO: "false"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
persist-credentials: false
|
||||
|
||||
# libasound2-dev is for cpal (audio playback).
|
||||
- name: Install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers \
|
||||
libasound2-dev
|
||||
|
||||
- name: Install Rust (stable + clippy, rustfmt, wasm32)
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
|
||||
--target wasm32-unknown-unknown
|
||||
rustup default stable
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Quality gate
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
+1
-1
@@ -28,7 +28,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
| `docs/images/filmcraft-keyframes.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
|
||||
| `docs/images/filmcraft-export.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frame from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
|
||||
| `docs/images/filmcraft-assembly.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Carnival of Souls (1962), Night of the Living Dead (1968), thumbnails of all demo media | MIT OR Apache-2.0; film frames public domain |
|
||||
| `docs/images/filmcraft-lumetri.png` | FilmCraft contributors | Original work: FilmCraft UI crop (Lumetri Color panel) | MIT OR Apache-2.0 |
|
||||
| `docs/images/filmcraft-color-panel.png` | FilmCraft contributors | Original work: FilmCraft UI crop (color grading panel) | MIT OR Apache-2.0 |
|
||||
| `docs/images/filmcraft-scopes.png` | FilmCraft contributors | Original work: FilmCraft UI crop (scopes of a Charade (1963) frame, public domain) | MIT OR Apache-2.0 |
|
||||
| `docs/images/filmcraft-effects.png` | FilmCraft contributors | Original work: FilmCraft screenshot crop; NASA Earth Views (US Government work, public domain) | MIT OR Apache-2.0; footage public domain |
|
||||
| `docs/images/filmcraft-timeline.png` | FilmCraft contributors | Original work: FilmCraft UI crop; thumbnails from Night of the Living Dead (1968) and Carnival of Souls (1962), CC0 Chopin waveform | MIT OR Apache-2.0; film frames public domain |
|
||||
|
||||
Generated
+58
-54
@@ -1679,7 +1679,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1704,6 +1704,7 @@ dependencies = [
|
||||
"pollster 0.4.0",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"subtle",
|
||||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
@@ -1712,7 +1713,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1722,7 +1723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1730,7 +1731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-apv"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1740,14 +1741,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
@@ -1763,7 +1764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1774,21 +1775,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-avi"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
@@ -1801,11 +1802,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
@@ -1827,7 +1828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
@@ -1865,7 +1866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1873,7 +1874,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dng"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-dng",
|
||||
"rayon",
|
||||
@@ -1882,7 +1883,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1892,7 +1893,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
@@ -1905,7 +1906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"filmcraft-audio-dsp",
|
||||
@@ -1940,7 +1941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-apv",
|
||||
@@ -1971,7 +1972,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-flac"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1980,7 +1981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
@@ -1988,12 +1989,13 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
@@ -2003,7 +2005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2011,7 +2013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -2028,7 +2030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
@@ -2047,7 +2049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2057,7 +2059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2067,7 +2069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2077,7 +2079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
@@ -2094,7 +2096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2105,7 +2107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
@@ -2113,7 +2115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-dng",
|
||||
@@ -2129,7 +2131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpa"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2138,7 +2140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
@@ -2147,7 +2149,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2155,28 +2157,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-platform"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-codecs",
|
||||
@@ -2202,7 +2204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
@@ -2216,7 +2218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2226,7 +2228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -2246,7 +2248,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
@@ -2255,7 +2257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2273,14 +2275,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
@@ -2292,7 +2294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2300,7 +2302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-tts"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2314,7 +2316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-tts-text"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2322,7 +2324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"craft-ui",
|
||||
@@ -2361,23 +2363,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp8"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7402,7 +7406,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -8,22 +8,29 @@
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png" width="128" height="128" alt="FilmCraft icon: an engraved owl on a purple tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">FilmCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Video editing, color and sound; an open-source, clean-room reimplementation of Adobe Premiere Pro, rebuilt in pure Rust.</b>
|
||||
<b>Video editing, color and sound; an open-source, professional non-linear video editor, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
An open-source, clean-room take on the Adobe Premiere Pro workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.<br>
|
||||
An open-source, modern non-linear video editing workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.<br>
|
||||
By the ArtCraft team.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#license-and-credits"><img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-8b5cf6"></a>
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-6a3fd6?logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows and Linux" src="https://img.shields.io/badge/runs%20on-macOS%20%7C%20Windows%20%7C%20Linux-8b5cf6">
|
||||
<a href="#status"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-6a3fd6"></a>
|
||||
<a href="#license-and-credits"><img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-8b5cf6?style=flat-square"></a>
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-6a3fd6?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows and Linux" src="https://img.shields.io/badge/runs%20on-macOS%20%7C%20Windows%20%7C%20Linux-8b5cf6?style=flat-square">
|
||||
<a href="#status"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-6a3fd6?style=flat-square"></a>
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_filmcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/filmcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -39,7 +46,7 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with Lumetri and Scale keyframes on the shot, the Lumetri Color panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters" width="100%">
|
||||
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with color grading and Scale keyframes on the shot, the color grading panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center"><sub><i>Apollo 11 - Tranquility</i>: a three-minute documentary edit of NASA's 1969 launch, landing and moonwalk film, with subtitles from the mission transcript. Every frame in these screenshots comes from public-domain footage, decoded, composited and graded by FilmCraft's own code.</sub></p>
|
||||
@@ -66,7 +73,7 @@
|
||||
|
||||
<br>
|
||||
|
||||
FilmCraft is a non-linear editor for people who know Premiere: the same panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can.
|
||||
FilmCraft is a professional non-linear editor: familiar panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can.
|
||||
|
||||
<br>
|
||||
|
||||
@@ -84,7 +91,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
|
||||
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
|
||||
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft and standard NLE key layouts.
|
||||
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.
|
||||
|
||||
<p align="center">
|
||||
@@ -103,7 +110,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
|
||||
<p align="center"><sub>The Color workspace on <i>Charade</i> (1963), with live scopes beside the Program monitor.</sub></p>
|
||||
|
||||
**A complete Lumetri-style grading panel**, in the order a colourist works:
|
||||
**A complete color grading panel**, in the order a colourist works:
|
||||
|
||||
- **Basic Correction:** temperature and tint, exposure, contrast, highlights, shadows, whites, blacks, saturation.
|
||||
- **Creative:** eight built-in looks with an intensity control, plus faded film, sharpen and vibrance. The looks are colour transforms written in code, not LUT files.
|
||||
@@ -115,11 +122,11 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
<table>
|
||||
<tr>
|
||||
<td width="34%" valign="top" align="center">
|
||||
<img src="docs/images/filmcraft-lumetri.png" alt="The Lumetri Color panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles"><br>
|
||||
<sub><b>The Lumetri Color panel.</b> Basic Correction, Color Wheels and every other section, each with its own on/off switch.</sub>
|
||||
<img src="docs/images/filmcraft-color-panel.png" alt="The color grading panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles"><br>
|
||||
<sub><b>The color grading panel.</b> Basic Correction, Color Wheels and every other section, each with its own on/off switch.</sub>
|
||||
</td>
|
||||
<td width="66%" valign="top" align="center">
|
||||
<img src="docs/images/filmcraft-scopes.png" alt="Lumetri Scopes: a green luma waveform and a vectorscope of a Charade (1963) frame"><br>
|
||||
<img src="docs/images/filmcraft-scopes.png" alt="The video scopes: a green luma waveform and a vectorscope of a Charade (1963) frame"><br>
|
||||
<sub><b>Scopes that tell the truth.</b> The waveform and vectorscope are computed from the graded frame. All grading happens in linear light, in 32-bit float.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -130,14 +137,14 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
## Effects and motion
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/filmcraft-effects.png" alt="Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and Lumetri Color, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions" width="100%">
|
||||
<img src="docs/images/filmcraft-effects.png" alt="Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and a colour grade, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center"><sub>NASA's <i>Earth Views from the ISS</i> with a look applied, and the Effects browser open on the dissolves.</sub></p>
|
||||
|
||||
- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
|
||||
- **A comprehensive Video Effects bin (over 90 effects across 16 categories), legacy effects, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
|
||||
- **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes.
|
||||
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
|
||||
- **Advanced keyframing:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
|
||||
- **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.
|
||||
|
||||
<p align="center">
|
||||
@@ -179,7 +186,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
|
||||
| **VP9** | ✓ | | Profiles 0–3, 8/10/12-bit, 4:2:0 to 4:4:4, tiles and superframes; bit-exact on 50+ streams; in WebM/Matroska and MP4 |
|
||||
| **Opus** | ✓ | | SILK, CELT, hybrid and multistream surround; range-exact on every RFC 8251 conformance vector; in WebM/Matroska and MP4 |
|
||||
| **AV1** | ✓ | | Main profile, 8/10-bit 4:2:0 and monochrome, every coding tool incl. film grain, superres and spatial layers; bit-exact with libdav1d on the libaom vectors tested; in MP4 and WebM/Matroska |
|
||||
| **Avid DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV |
|
||||
| **DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV |
|
||||
| **MJPEG, PCM** | ✓ | ✓ | |
|
||||
| **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own |
|
||||
| **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks |
|
||||
@@ -200,7 +207,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
|
||||
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
|
||||
|
||||
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes in ffmpeg without a single warning, with strict error detection on.
|
||||
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
|
||||
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and standard OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
|
||||
- **Background jobs** with progress and cancel, so you keep editing while it renders.
|
||||
- **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back.
|
||||
|
||||
@@ -210,11 +217,11 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
|
||||
|
||||
Move timelines between FilmCraft and every other editor:
|
||||
|
||||
- **Final Cut Pro 7 XML (xmeml):** the format Premiere and DaVinci Resolve exchange. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes.
|
||||
- **FCP 7 XML (xmeml):** industry-standard timeline interchange format. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes.
|
||||
- **FCPXML 1.9–1.11:** the spine, connected clips as lanes, transitions, retiming and compound clips.
|
||||
- **OpenTimelineIO:** the open interchange format of the film industry, round-tripping every FilmCraft detail through `metadata.filmcraft`.
|
||||
- **CMX 3600 EDL:** the oldest format still in use, with drop-frame timecode, dissolves, wipes, speed changes (`M2`) and one EDL per track.
|
||||
- **AAF (Edit Protocol):** for Avid Media Composer and Pro Tools. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
|
||||
- **AAF (Edit Protocol):** standard timeline and audio interchange format. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
|
||||
- **OMF 2.0:** the audio-post handoff: the audio tracks with crossfades and gain, sample-accurate, with the audio encapsulated or alongside.
|
||||
|
||||
Import merges the document's bins, media and sequences into your project as one undoable step, and links each media file it finds on disk.
|
||||
@@ -223,7 +230,7 @@ Import merges the document's bins, media and sequences into your project as one
|
||||
|
||||
## Built for agents
|
||||
|
||||
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
|
||||
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with more commands continuously added. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
|
||||
|
||||
```jsonc
|
||||
// over the control channel (JSON lines on TCP) or as MCP tool calls
|
||||
@@ -277,22 +284,22 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr
|
||||
| [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery |
|
||||
| [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats |
|
||||
| [ROADMAP.md](ROADMAP.md) | Stage, parity numbers, languages, what's next and the progress log |
|
||||
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment against Premiere Pro, the ranked list of shortfalls, milestones and current focus |
|
||||
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment, the ranked list of shortfalls, milestones and current focus |
|
||||
| [docs/codec-parity.md](docs/codec-parity.md) · [docs/file-format-parity.md](docs/file-format-parity.md) · [docs/hardware-parity.md](docs/hardware-parity.md) · [docs/ui-parity.md](docs/ui-parity.md) · [docs/localization-parity.md](docs/localization-parity.md) | Parity checklists by dimension |
|
||||
|
||||
## Status
|
||||
|
||||
FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today.
|
||||
|
||||
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10 against Premiere Pro 26.5.2):
|
||||
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10):
|
||||
|
||||
- **Feature breadth: ~86%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
|
||||
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
|
||||
- **Feature breadth: ~86%.** Professional NLE menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
|
||||
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to handling full production work on real projects.
|
||||
|
||||
The biggest gaps today:
|
||||
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but color grading, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **Native `.prproj` project import.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
|
||||
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
|
||||
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
|
||||
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
|
||||
@@ -455,15 +462,13 @@ with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds
|
||||
|
||||
**Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
FilmCraft is an independent implementation. It contains no Adobe code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle.
|
||||
FilmCraft is an independent implementation. It contains no proprietary code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and FilmCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. FilmCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -69,7 +69,9 @@ USAGE
|
||||
SUBCOMMANDS
|
||||
exec <id> [key=value ...] run one command; prints its result as JSON
|
||||
exec <id> '<json params>' same, params as one JSON object
|
||||
run <script.jsonl | -> run commands, one {\"id\",\"params\"} per line (# comments ok)
|
||||
run <script.jsonl | -> run commands, one {\"id\",\"params\"} per line (# comments ok), in
|
||||
order: file.exportMedia waits for its file before the next line
|
||||
(and before exit) unless its params say \"wait\": false
|
||||
commands [filter] [--json] list commands (id, label, shortcut, params)
|
||||
describe <id> one command as JSON (menu, shortcut, params, enabled now)
|
||||
inspect [project|sequence] project tree or active sequence as JSON (default: both)
|
||||
@@ -169,9 +171,21 @@ impl Backend {
|
||||
Backend::Local(Box::new(s))
|
||||
}
|
||||
|
||||
async fn exec(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
async fn exec(&mut self, id: &str, mut params: Value) -> Result<Value, String> {
|
||||
match self {
|
||||
Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()),
|
||||
// this process exits after the command, so a background export would never be written:
|
||||
// long commands wait unless the caller says otherwise (#2342)
|
||||
Backend::Local(s) => {
|
||||
if filmcraft_automation::long_job::LONG_COMMANDS.contains(&id)
|
||||
&& let Some(p) = params.as_object_mut()
|
||||
{
|
||||
// anything but an explicit `false` (missing, null, a typo'd value) waits
|
||||
if p.get("wait").and_then(Value::as_bool) != Some(false) {
|
||||
p.insert("wait".into(), json!(true));
|
||||
}
|
||||
}
|
||||
s.execute(id, params).map_err(|e| e.to_string())
|
||||
}
|
||||
// a blocking export runs as an app job polled by the client (#91, #92)
|
||||
Backend::Bridge(b) => b.execute(id, params).await.map_err(|e| e.to_string()),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).output().expect("spawn filmcraft-cli")
|
||||
@@ -150,6 +150,23 @@ fn export_wav_waits_for_job() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_waits_for_an_export_on_its_last_line() {
|
||||
// #2342: `file.exportMedia` as the last line of a script is written before `run` exits
|
||||
let dir = std::env::temp_dir().join(format!("fc-cli-run-x-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let out = dir.join("last.wav");
|
||||
// `"wait": null` waits too: only an explicit `false` doesn't
|
||||
let script = json!({"id": "file.exportMedia", "params": {"path": out.to_str().unwrap(), "format": "wav", "wait": null}}).to_string();
|
||||
let mut child =
|
||||
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--demo", "run", "-"]).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn().unwrap();
|
||||
child.stdin.take().unwrap().write_all(format!("{script}\n").as_bytes()).unwrap();
|
||||
let o = child.wait_with_output().unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout));
|
||||
assert!(std::fs::metadata(&out).map(|m| m.len() > 44).unwrap_or(false), "{}", String::from_utf8_lossy(&o.stdout));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `export --preset`: a built-in preset by name (dash or en dash), a custom range, a user preset
|
||||
/// from `--data-dir`, `--list-presets`, `--queue`, and a clean failure for an unknown preset.
|
||||
#[test]
|
||||
|
||||
@@ -352,6 +352,24 @@ impl Services for WebServices {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file_new(&self, path: &str, data: &[u8]) -> io::Result<()> {
|
||||
if path.starts_with("/opfs/") {
|
||||
return Err(io::Error::new(io::ErrorKind::Unsupported, "exclusive OPFS writes are unavailable"));
|
||||
}
|
||||
let hook = FS.with(|fs| {
|
||||
let mut fs = fs.borrow_mut();
|
||||
if fs.entries.contains_key(path) {
|
||||
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
|
||||
}
|
||||
fs.entries.insert(path.to_owned(), Entry::Mem(Arc::from(data)));
|
||||
Ok(fs.on_write.clone())
|
||||
})?;
|
||||
if let Some(h) = hook {
|
||||
h(path, data);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_size(&self, path: &str) -> io::Result<u64> {
|
||||
Self::entry_reader(path).map(|r| r.len())
|
||||
}
|
||||
@@ -435,3 +453,32 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exclusive_write_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn virtual_file_creation_is_exclusive_and_opfs_fails_closed() {
|
||||
let host = WebServices;
|
||||
let existing = "/files/collision-test.wav";
|
||||
let fresh = "/files/new-extraction-test.wav";
|
||||
FS.with(|fs| {
|
||||
let mut fs = fs.borrow_mut();
|
||||
fs.on_write = None;
|
||||
fs.entries.insert(existing.into(), Entry::Mem(Arc::from(&b"original"[..])));
|
||||
fs.entries.remove(fresh);
|
||||
});
|
||||
assert_eq!(host.write_file_new(existing, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(host.read_file(existing).unwrap(), b"original");
|
||||
host.write_file_new(fresh, b"new").unwrap();
|
||||
assert_eq!(host.read_file(fresh).unwrap(), b"new");
|
||||
assert_eq!(host.write_file_new(fresh, b"again").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(host.write_file_new("/opfs/media/new.wav", b"new").unwrap_err().kind(), io::ErrorKind::Unsupported);
|
||||
FS.with(|fs| {
|
||||
let mut fs = fs.borrow_mut();
|
||||
fs.entries.remove(existing);
|
||||
fs.entries.remove(fresh);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ filmcraft-platform = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
# Constant-time comparison of control-channel credentials.
|
||||
subtle = "2.6"
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = "0.16"
|
||||
|
||||
@@ -9,7 +9,8 @@ Usage: filmcraft [options] [project.fcproj | media files...]
|
||||
|
||||
Options:
|
||||
--control <port> start the localhost JSON-lines control server
|
||||
(also: FILMCRAFT_CONTROL_PORT)
|
||||
(also: FILMCRAFT_CONTROL_PORT; requires FILMCRAFT_CONTROL_TOKEN,
|
||||
64 random hex characters; see docs/control-protocol.md)
|
||||
--control-input-only accept widget input only from the control server
|
||||
(requires --control or FILMCRAFT_CONTROL_PORT)
|
||||
--demo open the demo project
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
//! never reaches the UI thread. Every request must carry the session credential from
|
||||
//! `FILMCRAFT_CONTROL_TOKEN`; without a configured credential the listener stays closed.
|
||||
//! See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
@@ -15,6 +17,7 @@ use std::time::Duration;
|
||||
|
||||
use filmcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
@@ -23,7 +26,17 @@ pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
start_with_token(port, ctx, std::env::var("FILMCRAFT_CONTROL_TOKEN").ok())
|
||||
}
|
||||
|
||||
fn start_with_token(port: u16, ctx: egui::Context, token: Option<String>) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// An inherited environment variable shares the credential with the owner's bridge process
|
||||
// without exposing it in command-line arguments, logs, or a shared discovery file.
|
||||
let Some(token) = token.filter(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())) else {
|
||||
eprintln!("filmcraft: control server disabled: FILMCRAFT_CONTROL_TOKEN must contain 64 random hex characters (see docs/control-protocol.md)");
|
||||
return rx;
|
||||
};
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -32,11 +45,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("filmcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx, token));
|
||||
rx
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context, token: String) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
@@ -46,12 +59,13 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = token.clone();
|
||||
// The slot is given back when the guard drops: after `serve` returns, if it panics, or if
|
||||
// the thread can't be started (the closure, and with it the guard, is dropped).
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let spawned = std::thread::Builder::new().name("control-conn".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx);
|
||||
serve(stream, &tx, &ctx, &token);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
eprintln!("filmcraft: control server could not start a connection thread: {e}");
|
||||
@@ -68,9 +82,9 @@ impl Drop for Slot {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, token: &str) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
serve_lines(read, stream, token, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
@@ -90,7 +104,7 @@ enum Line {
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
fn parse_line(bytes: &[u8], token: &str) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
@@ -108,6 +122,12 @@ fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
// Authenticate every request before constructing or enqueueing a ControlRequest. A valid
|
||||
// earlier request must not authorize a later one with a missing or incorrect credential.
|
||||
let authenticated = obj.get("token").and_then(Value::as_str).is_some_and(|supplied| bool::from(supplied.as_bytes().ct_eq(token.as_bytes())));
|
||||
if !authenticated {
|
||||
return Line::Reject("control authentication failed; closing the connection".into());
|
||||
}
|
||||
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
|
||||
}
|
||||
|
||||
@@ -138,14 +158,14 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, token: &str, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Ok(true) => match parse_line(&buf, token) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
@@ -169,10 +189,12 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
serve_lines(input, &mut out, TOKEN, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
@@ -182,7 +204,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
let input = format!(
|
||||
"{{\"token\":\"{TOKEN}\",\"id\":1,\"method\":\"a\"}}\n\n{{\"token\":\"{TOKEN}\",\"id\":2,\"method\":\"b\",\"params\":{{}}}}\r\n{{\"token\":\"{TOKEN}\",\"method\":\"c\"}}"
|
||||
);
|
||||
let (r, calls) = run(input.as_bytes());
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
@@ -220,7 +245,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
let input = format!("{{\"token\":\"{TOKEN}\",\"method\":\"a\"}}\nGET / HTTP/1.1\n{{\"token\":\"{TOKEN}\",\"method\":\"b\"}}\n");
|
||||
let (r, calls) = run(input.as_bytes());
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
@@ -256,11 +282,11 @@ mod tests {
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
serve_lines(read, s, TOKEN, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
c.write_all(format!("{{\"token\":\"{TOKEN}\",\"id\":7,\"method\":\"x\"}}\nhello\n{{\"token\":\"{TOKEN}\",\"method\":\"y\"}}\n").as_bytes()).unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
@@ -275,7 +301,7 @@ mod tests {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default(), TOKEN.into()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
@@ -285,4 +311,91 @@ mod tests {
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_invalid_token_leaves_the_listener_closed() {
|
||||
for token in [None, Some(String::new()), Some("a".repeat(63)), Some("a".repeat(65)), Some("z".repeat(64))] {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let rx = start_with_token(port, egui::Context::default(), token);
|
||||
assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));
|
||||
let _still_available = TcpListener::bind(("127.0.0.1", port)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthenticated_save_as_closes_before_any_command_runs() {
|
||||
for token in
|
||||
[Value::Null, json!(false), json!(42), json!([]), json!({}), json!(""), json!("a".repeat(64)), json!(&TOKEN[..63]), json!(format!("{TOKEN}0"))]
|
||||
{
|
||||
let mut request = json!({"id": 1, "method": "engine.execute", "params": {"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}}});
|
||||
if !token.is_null() {
|
||||
request["token"] = token;
|
||||
}
|
||||
// A valid request after a rejected one must never execute, either.
|
||||
let input = format!("{request}\n{}\n", json!({"token": TOKEN, "method": "ui.inspect"}));
|
||||
let (replies, calls) = run(input.as_bytes());
|
||||
assert!(calls.is_empty());
|
||||
assert_eq!(replies, [json!({"ok": false, "error": "control authentication failed; closing the connection"})]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_is_required_on_every_request() {
|
||||
let input = format!(
|
||||
"{}\n{}\n{}\n",
|
||||
json!({"token": TOKEN, "method": "ui.inspect"}),
|
||||
json!({"method": "app.quit"}),
|
||||
json!({"token": TOKEN, "method": "app.quit"})
|
||||
);
|
||||
let (replies, calls) = run(input.as_bytes());
|
||||
assert_eq!(calls, ["ui.inspect"]);
|
||||
assert_eq!(replies.len(), 2);
|
||||
assert_eq!(replies[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_authentication_happens_before_enqueueing() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
serve(s, &tx, &egui::Context::default(), TOKEN);
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"method\":\"engine.execute\",\"params\":{\"command\":\"file.saveAs\",\"params\":{\"path\":\"/owner/project.fcproj\"}}}\n").unwrap();
|
||||
c.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let mut reply = String::new();
|
||||
c.read_to_string(&mut reply).unwrap();
|
||||
server.join().unwrap();
|
||||
assert!(reply.contains("control authentication failed"));
|
||||
assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_tcp_request_reaches_the_ui_queue() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
serve(s, &tx, &egui::Context::default(), TOKEN);
|
||||
});
|
||||
let params = json!({"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
writeln!(c, "{}", json!({"id": 7, "token": TOKEN, "method": "engine.execute", "params": params})).unwrap();
|
||||
c.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let request = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(request.method, "engine.execute");
|
||||
assert_eq!(request.params, params);
|
||||
request.reply.send(json!({"ok": true})).unwrap();
|
||||
let mut reply = String::new();
|
||||
c.read_to_string(&mut reply).unwrap();
|
||||
server.join().unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&reply).unwrap(), json!({"id": 7, "ok": true}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,8 @@ fn desktop_file_content(app_id: &str, name: &str, exe: &str) -> String {
|
||||
out.push_str("StartupNotify=true\n");
|
||||
out.push_str(&format!("StartupWMClass={app_id}\n"));
|
||||
out.push_str("Categories=AudioVideo;Video;AudioVideoEditing;\n");
|
||||
out.push_str("MimeType=application/x-filmcraft;video/mp4;video/x-m4v;video/quicktime;video/x-matroska;video/webm;");
|
||||
out.push_str("video/vnd.avi;video/x-msvideo;application/mxf;video/mp2t;video/mpeg;video/3gpp;\n");
|
||||
out.push_str("Keywords=video;editor;film;timeline;color;grading;nle;\n");
|
||||
out
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! (the demo project), Open Most Recent, or an empty project.
|
||||
//!
|
||||
//! `--control <port>` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
||||
//! `FILMCRAFT_CONTROL_TOKEN` is required (see `docs/control-protocol.md`).
|
||||
//! see `filmcraft_ui_egui::control` for the methods. A port that is not a number is an error from
|
||||
//! either source.
|
||||
//!
|
||||
|
||||
@@ -19,6 +19,15 @@ fn accel(s: &str) -> Option<Accelerator> {
|
||||
mapped.parse().ok()
|
||||
}
|
||||
|
||||
/// The app menu's Quit item: the `app.quit` command (which asks to save unsaved changes) on its
|
||||
/// shortcut, Cmd+Q unless remapped in the Keyboard Shortcuts editor. The key equivalent is read
|
||||
/// once, when the menu bar is built: `app.quit` has no menu path, so it is not in the menu tree the
|
||||
/// updater receives, and a remap shows on the Quit item after the next launch.
|
||||
fn quit_item(app: &FilmcraftApp) -> (&'static str, Option<Accelerator>) {
|
||||
let keys = app.session.shortcuts.primary("app.quit").unwrap_or_else(|| "Cmd+Q".into());
|
||||
("app.quit", accel(&keys))
|
||||
}
|
||||
|
||||
/// Updates native item labels, key equivalents and enablement when the menu tree changes.
|
||||
pub type MenuUpdater = Box<dyn FnMut(&[Item])>;
|
||||
|
||||
@@ -53,9 +62,12 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
||||
let predefined = [
|
||||
(hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))),
|
||||
(hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))),
|
||||
(quit_label, PredefinedMenuItem::quit(Some(app.ui.language.tr(quit_label)))),
|
||||
];
|
||||
let [(_, hide), (_, hide_others), (_, quit)] = &predefined;
|
||||
let [(_, hide), (_, hide_others)] = &predefined;
|
||||
// Quit is ours, not the predefined item: that one calls `terminate:` and skips the unsaved
|
||||
// changes prompt (#1943); the `app.quit` command closes the window through it
|
||||
let (quit_id, quit_accel) = quit_item(app);
|
||||
let quit = MenuItem::with_id(quit_id, app.ui.language.tr(quit_label), true, quit_accel);
|
||||
let _ = app_menu.append_items(&[
|
||||
&about,
|
||||
&discord,
|
||||
@@ -65,7 +77,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
||||
hide,
|
||||
hide_others,
|
||||
&PredefinedMenuItem::separator(),
|
||||
quit,
|
||||
&quit,
|
||||
]);
|
||||
let _ = bar.append(&app_menu);
|
||||
for top in MENUS {
|
||||
@@ -147,6 +159,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
||||
for (label, item) in &predefined {
|
||||
item.set_text(language.tr(label));
|
||||
}
|
||||
quit.set_text(language.tr(quit_label));
|
||||
for (shown, mi) in &mut native {
|
||||
let Some(it) = items.iter().find(|i| i.id == shown.id) else { continue };
|
||||
if it.label != shown.label {
|
||||
@@ -164,3 +177,20 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
||||
});
|
||||
(rx, update)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use filmcraft_engine::Session;
|
||||
|
||||
/// #1943: Cmd+Q must reach the `app.quit` command, which closes the window through the unsaved
|
||||
/// changes prompt, instead of the predefined Quit item's `terminate:`.
|
||||
#[test]
|
||||
fn quit_item_runs_the_app_quit_command_on_cmd_q() {
|
||||
let app = FilmcraftApp::new(Session::default());
|
||||
let (id, accelerator) = quit_item(&app);
|
||||
assert_eq!(id, "app.quit");
|
||||
assert_eq!(accelerator, accel("Cmd+Q"));
|
||||
assert!(accelerator.is_some(), "Cmd+Q is a native key equivalent");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
|
||||
//! truncation at (nearly) every length must never panic or run away (errors are fine).
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use filmcraft_apv::*;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn seeds() -> Vec<Vec<u8>> {
|
||||
let mut v = Vec::new();
|
||||
for (p, w, h) in [(Profile::P422_10, 64u32, 48u32), (Profile::P444_10, 48, 32), (Profile::P4444_10, 32, 32), (Profile::P400_10, 32, 32)] {
|
||||
let chroma = p.default_chroma();
|
||||
let depth = p.default_bit_depth();
|
||||
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||
for (i, s) in f.y.iter_mut().enumerate() {
|
||||
*s = (64 + (i * 37) % 800) as u16;
|
||||
}
|
||||
let mut cfg = EncoderConfig::new(p, w, h);
|
||||
cfg.qp = Some(16);
|
||||
let Ok(mut enc) = Encoder::with_config(cfg) else { continue };
|
||||
if let Ok(d) = enc.encode(&f) {
|
||||
v.push(d);
|
||||
}
|
||||
if let Ok(d) = enc.encode_raw_au(&f) {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn run(d: &[u8], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for depth in [None, Some(8), Some(16)] {
|
||||
let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() });
|
||||
}
|
||||
let _ = probe(d);
|
||||
let _ = split_raw_bitstream(d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic: {label}");
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_frames_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let seeds = seeds();
|
||||
assert!(!seeds.is_empty());
|
||||
for (si, seed) in seeds.iter().enumerate() {
|
||||
let stride = (seed.len() / 400).max(1);
|
||||
for n in (0..seed.len()).step_by(stride) {
|
||||
run(&seed[..n], &format!("seed {si} truncate {n}"));
|
||||
}
|
||||
for p in 0..seed.len().min(256) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[p] = v;
|
||||
run(&d, &format!("seed {si} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[p] ^= 1 << b;
|
||||
run(&d, &format!("seed {si} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
// Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions).
|
||||
for p in 0..seed.len().min(128) {
|
||||
for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] {
|
||||
let mut d = seed.clone();
|
||||
if let Some(s) = d.get_mut(p..p + 4) {
|
||||
s.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
run(&d, &format!("seed {si} u32 {p}={v:#x}"));
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
|
||||
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000);
|
||||
for i in 0..iters {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let p = rng.below(d.len());
|
||||
if rng.below(2) == 0 {
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run(&d, &format!("seed {si} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time", "fs"] }
|
||||
base64 = "0.22"
|
||||
schemars = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
@@ -20,6 +20,8 @@ type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWr
|
||||
|
||||
pub struct BridgeClient {
|
||||
addr: String,
|
||||
/// Inherited from the same owner-controlled environment as the desktop app.
|
||||
token: Option<String>,
|
||||
/// Connections with no request in flight. A request takes one (or opens one) for its whole
|
||||
/// round trip and puts it back only once the reply is read, so a slow or abandoned request
|
||||
/// never holds up the others or leaves its late reply for the next one to read (#1538).
|
||||
@@ -28,14 +30,15 @@ pub struct BridgeClient {
|
||||
}
|
||||
|
||||
impl BridgeClient {
|
||||
/// `addr` such as `127.0.0.1:9876` (loopback only).
|
||||
/// `addr` such as `127.0.0.1:9876` (loopback only). Reads `FILMCRAFT_CONTROL_TOKEN`
|
||||
/// from the environment; the desktop app requires this credential on every request.
|
||||
pub fn new(addr: impl Into<String>) -> Result<Self, AutomationError> {
|
||||
let addr = addr.into();
|
||||
let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr);
|
||||
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
|
||||
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
|
||||
}
|
||||
Ok(Self { addr, idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
|
||||
Ok(Self { addr, token: std::env::var("FILMCRAFT_CONTROL_TOKEN").ok(), idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
|
||||
}
|
||||
|
||||
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
|
||||
@@ -85,7 +88,7 @@ impl BridgeClient {
|
||||
None => self.connect().await?,
|
||||
};
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
|
||||
let line = format!("{}\n", json!({"id": id, "token": self.token, "method": method, "params": params}));
|
||||
let res: Result<Value, AutomationError> = async {
|
||||
conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;
|
||||
let mut buf = String::new();
|
||||
@@ -282,4 +285,19 @@ mod tests {
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert_eq!(seen[1]["params"]["command"], "sequence.inspect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_is_sent_on_every_request() {
|
||||
let (addr, seen) = fake_app(Value::Null).await;
|
||||
let mut b = BridgeClient::new(addr).unwrap();
|
||||
// Inject directly to avoid mutating the process environment in parallel tests.
|
||||
let token = "0123456789abcdef".repeat(4);
|
||||
b.token = Some(token.clone());
|
||||
b.call("ui.inspect", json!({})).await.unwrap();
|
||||
b.execute("file.saveAs", json!({"path": "/owner/project.fcproj"})).await.unwrap();
|
||||
let seen = seen.lock().await;
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert!(seen.iter().all(|request| request["token"] == token));
|
||||
assert_eq!(seen[1]["params"]["params"]["path"], "/owner/project.fcproj");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,11 +568,11 @@ impl FilmcraftMcp {
|
||||
if let Err(e) = b.call("ui.screenshot", json!({"path": path.to_string_lossy(), "panel": panel})).await {
|
||||
return Ok(fail(e));
|
||||
}
|
||||
let bytes = match std::fs::read(&path) {
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Ok(fail(format!("screenshot: {e}"))),
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
let bytes = match image::load_from_memory(&bytes) {
|
||||
Ok(img) => {
|
||||
let rgba = img.to_rgba8();
|
||||
|
||||
@@ -125,6 +125,13 @@ motion vectors nor CDFs reach another frame — skip deblocking, CDEF and loop r
|
||||
(super-resolution still runs, it sets the output size) and are flagged `Picture::draft`. In
|
||||
SVT-AV1's hierarchical GOPs that is the top layer, half of the frames.
|
||||
|
||||
## Limits
|
||||
|
||||
Frames beyond the AV1 level 6.x limits (Annex A: at most 16384 wide, 8704 high and 35 651 584 luma samples
|
||||
in total, so 16384 x 2176 or 8192 x 4352 at most) are refused with `Error::Invalid("frame size")`. The sequence
|
||||
header can code up to 65536 x 65536, which would ask for gigabytes of buffers from a few bytes of hostile input;
|
||||
no real stream comes close to level 6.3, the highest defined level.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
|
||||
@@ -23,6 +23,17 @@ use crate::stats::{DecodeStats, Stage, Timer};
|
||||
use crate::tile::TileDecoder;
|
||||
use crate::{Error, Picture, Result};
|
||||
|
||||
/// Largest accepted frame: the AV1 level 6.x limits (Annex A: MaxHSize 16384, MaxVSize 8704, MaxPicSize
|
||||
/// 35 651 584 luma samples; level 6.3 is the highest defined). The sequence header allows 65536 x 65536
|
||||
/// (gigabytes of buffers), so nothing larger than a level 6.3 frame is decoded.
|
||||
const MAX_FRAME_WIDTH: u32 = 16384;
|
||||
const MAX_FRAME_HEIGHT: u32 = 8704;
|
||||
const MAX_FRAME_AREA: u64 = 35_651_584;
|
||||
|
||||
/// Whether a frame of `width` x `height` luma samples is within the decoder's resource limits.
|
||||
fn frame_size_allowed(width: u32, height: u32) -> bool {
|
||||
width <= MAX_FRAME_WIDTH && height <= MAX_FRAME_HEIGHT && u64::from(width) * u64::from(height) <= MAX_FRAME_AREA
|
||||
}
|
||||
const OBU_SEQUENCE_HEADER_T: u32 = 1;
|
||||
const OBU_TEMPORAL_DELIMITER_T: u32 = 2;
|
||||
const OBU_FRAME_HEADER_T: u32 = 3;
|
||||
@@ -505,7 +516,7 @@ impl Decoder {
|
||||
}
|
||||
// Encoders exist that code frames slightly larger than the sequence maximum (e.g. a
|
||||
// 270-line stream coded as 272 lines); like other decoders we accept them.
|
||||
if fh.upscaled_width > 65536 || fh.frame_height > 65536 {
|
||||
if !frame_size_allowed(fh.upscaled_width, fh.frame_height) {
|
||||
return Err(Error::Invalid("frame size"));
|
||||
}
|
||||
if !fh.frame_is_intra {
|
||||
@@ -947,3 +958,26 @@ fn project(fs: &mut FrameState, src: usize, dst_sign: i32) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::frame_size_allowed;
|
||||
|
||||
#[test]
|
||||
fn frames_up_to_the_level_6_3_limits_are_allowed() {
|
||||
assert!(frame_size_allowed(16384, 2176)); // exactly MaxPicSize
|
||||
assert!(frame_size_allowed(4096, 8704)); // exactly MaxVSize
|
||||
assert!(frame_size_allowed(8192, 4352)); // exactly MaxPicSize
|
||||
assert!(frame_size_allowed(7680, 4320)); // 8K UHD
|
||||
assert!(frame_size_allowed(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_beyond_the_limits_are_refused() {
|
||||
assert!(!frame_size_allowed(16384, 2177)); // one row over MaxPicSize
|
||||
assert!(!frame_size_allowed(16384, 8704)); // within both sides, four times MaxPicSize
|
||||
assert!(!frame_size_allowed(4096, 8705)); // one row over MaxVSize
|
||||
assert!(!frame_size_allowed(16385, 1)); // one column over MaxHSize
|
||||
assert!(!frame_size_allowed(65536, 65536));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +394,10 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
fn decode_block(&mut self, r: usize, c: usize, sub_size: usize) -> Result<()> {
|
||||
let (ssx, ssy) = (self.fs.ssx, self.fs.ssy);
|
||||
let num_planes = self.fs.num_planes;
|
||||
// Conformance requirement (4:2:2 has no chroma size for 4x16 and similar blocks); indexing the size tables with BLOCK_INVALID would panic.
|
||||
if num_planes > 1 && self.fs.plane_residual_size(sub_size, 1) == BLOCK_INVALID {
|
||||
return Err(crate::Error::Invalid("block size has no chroma size at this subsampling"));
|
||||
}
|
||||
let mut b = Block { mi_row: r, mi_col: c, mi_size: sub_size, ..Default::default() };
|
||||
b.bw4 = NUM_4X4_BLOCKS_WIDE[sub_size] as usize;
|
||||
b.bh4 = NUM_4X4_BLOCKS_HIGH[sub_size] as usize;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
|
||||
//! ffmpeg-generated AV1 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use filmcraft_av1::Decoder;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// (name, encoder args, pix_fmt)
|
||||
const SEEDS: &[(&str, &str, &str)] = &[("av1_fz_default", "-c:v libsvtav1 -g 4", "yuv420p"), ("av1_fz_10bit", "-c:v libsvtav1 -g 6", "yuv420p10le")];
|
||||
|
||||
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let Some(h) = data.get(6..8) else { return out };
|
||||
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
|
||||
while let Some(hd) = data.get(pos..pos + 12) {
|
||||
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
|
||||
pos += 12;
|
||||
let end = pos.saturating_add(size).min(data.len());
|
||||
out.push(data[pos..end].to_vec());
|
||||
pos = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
|
||||
let out = filmcraft_testkit::fixtures_dir("av1").join(format!("{name}.ivf"));
|
||||
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
let a: Vec<&str> = args.split_whitespace().collect();
|
||||
matches!(
|
||||
Command::new(ff)
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
|
||||
.args(a)
|
||||
.args(["-f", "ivf"])
|
||||
.arg(tmp)
|
||||
.stdin(Stdio::null())
|
||||
.status(),
|
||||
Ok(s) if s.success()
|
||||
)
|
||||
})?;
|
||||
Some(ivf_frames(&std::fs::read(p).ok()?))
|
||||
}
|
||||
|
||||
static PANICS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test.
|
||||
fn count_panics() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
PANICS.fetch_add(1, Ordering::SeqCst);
|
||||
eprintln!("PANIC: {info}");
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
fn run_all(frames: &[Vec<u8>], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
count_panics();
|
||||
let before = PANICS.load(Ordering::SeqCst);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for threads in [1, 3] {
|
||||
let mut dec = Decoder::with_threads(threads);
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let _ = dec.decode_pts(f, i as i64);
|
||||
}
|
||||
let _ = dec.flush_result();
|
||||
}
|
||||
}));
|
||||
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
|
||||
}
|
||||
|
||||
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
|
||||
fn iters() -> usize {
|
||||
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("AV1 fuzz") else { return };
|
||||
for &(name, args, pix) in SEEDS {
|
||||
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libsvtav1") {
|
||||
filmcraft_testkit::skip("AV1 fuzz", &format!("this ffmpeg has no libsvtav1 encoder (seed {name})"));
|
||||
continue;
|
||||
}
|
||||
let Some(seed) = make(&ff, name, args, pix) else {
|
||||
panic!("ffmpeg has libsvtav1 but could not encode the {name} seed");
|
||||
};
|
||||
if seed.is_empty() {
|
||||
filmcraft_testkit::skip("AV1 fuzz", &format!("the {name} seed has no frames"));
|
||||
continue;
|
||||
}
|
||||
// truncate each of the first frames at every length
|
||||
for fi in 0..seed.len().min(3) {
|
||||
let len = seed[fi].len();
|
||||
for n in (0..len).step_by((len / 200).max(1)) {
|
||||
let mut d = seed.clone();
|
||||
d[fi].truncate(n);
|
||||
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
|
||||
}
|
||||
}
|
||||
// header region of every frame: every byte to a few values, every bit
|
||||
for fi in 0..seed.len() {
|
||||
for p in 0..seed[fi].len().min(96) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[fi][p] = v;
|
||||
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[fi][p] ^= 1 << b;
|
||||
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
|
||||
for i in 0..iters() {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(6) {
|
||||
let fi = rng.below(d.len());
|
||||
if d[fi].is_empty() {
|
||||
continue;
|
||||
}
|
||||
let p = rng.below(d[fi].len());
|
||||
if rng.below(2) == 0 {
|
||||
d[fi][p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[fi][p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run_all(&d, &format!("{name} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! A few bytes must not make the decoder allocate gigabytes: oversized sequences are refused.
|
||||
//! Hand-built (no ffmpeg): a reduced still-picture sequence header plus a lossless key frame whose
|
||||
//! tiles are one zero byte each.
|
||||
|
||||
use filmcraft_av1::{Decoder, Error};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Bits {
|
||||
bytes: Vec<u8>,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl Bits {
|
||||
fn put(&mut self, value: u32, bits: usize) {
|
||||
for i in (0..bits).rev() {
|
||||
if self.n.is_multiple_of(8) {
|
||||
self.bytes.push(0);
|
||||
}
|
||||
if (value >> i) & 1 == 1
|
||||
&& let Some(b) = self.bytes.last_mut()
|
||||
{
|
||||
*b |= 0x80 >> (self.n % 8);
|
||||
}
|
||||
self.n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn obu(kind: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![kind << 3 | 0b10];
|
||||
let mut n = payload.len();
|
||||
loop {
|
||||
let b = (n & 0x7f) as u8;
|
||||
n >>= 7;
|
||||
v.push(if n > 0 { b | 0x80 } else { b });
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
fn tile_log2(blk: u32, target: u32) -> u32 {
|
||||
let mut k = 0;
|
||||
while (blk << k) < target {
|
||||
k += 1;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// Temporal delimiter, reduced still-picture sequence header of `w` x `h` (64x64 superblocks) and a
|
||||
/// lossless key frame whose uniformly spaced tiles are one zero byte each.
|
||||
fn stream(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut s = Bits::default();
|
||||
s.put(0, 3); // seq_profile
|
||||
s.put(1, 1); // still_picture
|
||||
s.put(1, 1); // reduced_still_picture_header
|
||||
s.put(0, 5); // seq_level_idx
|
||||
s.put(15, 4); // frame_width_bits_minus_1
|
||||
s.put(15, 4); // frame_height_bits_minus_1
|
||||
s.put(w - 1, 16);
|
||||
s.put(h - 1, 16);
|
||||
s.put(0, 3 + 3 + 7 + 1); // sb size, filter intra, edge; superres, cdef, restoration; color config; film grain
|
||||
s.put(1, 1); // trailing bit
|
||||
|
||||
let (sb_cols, sb_rows) = (w.div_ceil(64), h.div_ceil(64));
|
||||
let cols_log2 = tile_log2(64, sb_cols); // MAX_TILE_WIDTH / 64 superblocks
|
||||
let min_log2_tiles = cols_log2.max(tile_log2(2304, sb_rows * sb_cols));
|
||||
let rows_log2 = min_log2_tiles.saturating_sub(cols_log2);
|
||||
let tiles = (sb_cols.div_ceil(sb_cols.div_ceil(1 << cols_log2))) * (sb_rows.div_ceil(sb_rows.div_ceil(1 << rows_log2)));
|
||||
let mut f = Bits::default();
|
||||
f.put(0b0001, 4); // disable_cdf_update, allow_screen_content_tools, render_and_frame_size_different, uniform_tile_spacing_flag
|
||||
f.put(0, 2); // no increment of the tile column / row counts
|
||||
if tiles > 1 {
|
||||
f.put(0, (cols_log2 + rows_log2) as usize + 2); // context_update_tile_id, tile_size_bytes_minus_1
|
||||
}
|
||||
f.put(0, 8 + 4 + 1 + 1); // base_q_idx 0 (lossless), delta flags, using_qmatrix, segmentation_enabled, reduced_tx_set
|
||||
let mut frame = f.bytes;
|
||||
frame.push(0); // tile_start_and_end_present_flag
|
||||
for _ in 1..tiles {
|
||||
frame.extend([0, 0]); // tile_size_minus_1 = 0, one data byte
|
||||
}
|
||||
frame.extend([0, 0]);
|
||||
|
||||
let mut out = obu(2, &[]);
|
||||
out.extend(obu(1, &s.bytes));
|
||||
out.extend(obu(6, &frame));
|
||||
out
|
||||
}
|
||||
|
||||
fn refused(w: u32, h: u32) {
|
||||
let mut dec = Decoder::new();
|
||||
let r = dec.decode(&stream(w, h)).and_then(|_| dec.flush_result());
|
||||
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("frame size")), "{w}x{h} must be refused as too large, got {:?}", r.as_ref().map(|p| p.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_wide_frame_is_refused() {
|
||||
refused(65536, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_tall_frame_is_refused() {
|
||||
refused(16, 65536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_frames_are_refused_without_allocating() {
|
||||
refused(65536, 65536);
|
||||
refused(65535, 65535);
|
||||
refused(30000, 30000);
|
||||
}
|
||||
|
||||
/// Frames past the level 6.x limits (width 16384, height 8704, area 35 651 584) are refused. The accepted side of
|
||||
/// each boundary is unit-tested on `frame_size_allowed` in `decoder.rs`: decoding a maximum-size frame end to end
|
||||
/// needs gigabytes.
|
||||
#[test]
|
||||
fn frames_past_the_level_6_limits_are_refused() {
|
||||
refused(16384, 2177);
|
||||
refused(16384, 8704);
|
||||
refused(4096, 8705);
|
||||
}
|
||||
|
||||
/// A 64x48 4:2:0 key frame whose sequence profile bit was flipped to 2 (4:2:2): blocks such as 4x16 then have no
|
||||
/// chroma size (BLOCK_INVALID) and used to index the size tables out of bounds.
|
||||
const PROFILE_FLIPPED: &[u8] = &[
|
||||
0x12, 0x00, 0x0a, 0x0b, 0x42, 0x00, 0x00, 0x05, 0x55, 0x7f, 0xbc, 0x6a, 0xf9, 0x00, 0x40, 0x32, 0x81, 0x05, 0x14, 0x00, 0xa5, 0xa0, 0x40, 0xd9, 0x52, 0xb9,
|
||||
0xe7, 0x3c, 0xa6, 0xc1, 0xe4, 0xe9, 0xff, 0x6d, 0x12, 0xc3, 0xce, 0xac, 0x00, 0x92, 0x6f, 0x29, 0x6a, 0xee, 0x7e, 0x1a, 0x31, 0xfe, 0xe6, 0xb8, 0x2e, 0xe2,
|
||||
0xcf, 0x3d, 0xce, 0x24, 0xe6, 0x30, 0x03, 0xcd, 0x89, 0x49, 0x1f, 0xa7, 0x8a, 0x75, 0xb3, 0x93, 0x27, 0x7b, 0x2f, 0x39, 0xc7, 0x60, 0xb7, 0xf8, 0x69, 0xbc,
|
||||
0x53, 0x71, 0x30, 0x64, 0x20, 0x7e, 0xc1, 0x10, 0x17, 0xe4, 0xca, 0x34, 0xe9, 0xae, 0x47, 0x39, 0x68, 0x68, 0xef, 0x33, 0x9f, 0x26, 0xe2, 0x3f, 0xf1, 0xef,
|
||||
0x02, 0xb7, 0xfd, 0xb1, 0x78, 0x4c, 0x35, 0x31, 0x17, 0x27, 0x11, 0x2f, 0xd7, 0x25, 0x01, 0x30, 0xa5, 0x7a, 0xaf, 0x9b, 0x5b, 0x3b, 0x8d, 0x9e, 0xd5, 0x23,
|
||||
0x06, 0x22, 0x3a, 0xec, 0x57, 0xae, 0xdb, 0x4d, 0x00, 0x36, 0xff, 0xbd, 0x3b, 0xef, 0x0a, 0xf0, 0x39, 0x2a, 0xa9, 0x2a, 0x56, 0x9d, 0x7e, 0xc2, 0x05, 0x46,
|
||||
0x21, 0x8b, 0xd9, 0x45, 0x33, 0x59, 0x3c, 0x40, 0xe0, 0x2b, 0xec, 0x51, 0x0a, 0xf2, 0xf0, 0xe5, 0x61, 0x99, 0xfd, 0x2e, 0xe5, 0x8c, 0x69, 0x72, 0x44, 0xb8,
|
||||
0x92, 0x9e, 0x2e, 0x51, 0x0b, 0x8c, 0x16, 0x0f, 0xd9, 0xf2, 0x47, 0xc4, 0x36, 0x9d, 0x54, 0xc6, 0x9f, 0x26, 0x68, 0xf7, 0xfa, 0x80, 0xab, 0x68, 0xd6, 0x61,
|
||||
0xd2, 0x59, 0x4b, 0x07, 0xc4, 0x7a, 0xde, 0x37, 0xfb, 0x02, 0xd5, 0x3d, 0xb0, 0xeb, 0x71, 0x1b, 0x07, 0x45, 0xdc, 0x03, 0x3b, 0x4f, 0xdf, 0x44, 0xa6, 0x28,
|
||||
0xd4, 0xb0, 0xbb, 0x26, 0xa4, 0x4f, 0xee, 0xbb, 0xda, 0x59, 0x69, 0xfc, 0xf2, 0x4f, 0xf6, 0x6c, 0x0c, 0xd9, 0xe2, 0xc0, 0xa7, 0x64, 0x96, 0xd5, 0x7b, 0x1a,
|
||||
0x71, 0xe0, 0xcc, 0x9e, 0xd7, 0xaf, 0xb9, 0x9a, 0x99, 0xcc, 0xe9, 0xa9, 0x79, 0xa3, 0xa8, 0xbc, 0xfe, 0xdb, 0xed, 0xa3, 0xd6, 0x7d, 0x31, 0xb0, 0x18, 0x3c,
|
||||
0x9e, 0x55, 0x0f, 0x1a, 0xdf, 0x5d, 0x8c, 0x33, 0x5f, 0x2d, 0x00, 0xfc, 0x9d, 0x55, 0xc2, 0xc8, 0x10, 0x25, 0xc8, 0x7f, 0xa6, 0x47, 0xbf, 0xab, 0xf3, 0x97,
|
||||
0xfb, 0xda, 0xd6, 0x6a, 0x14, 0x87, 0x9d, 0xef, 0x85, 0x12, 0x43, 0x31, 0xc4, 0x4c, 0x4c, 0x7a, 0x50, 0xd1, 0x87, 0xb8, 0x65, 0x2d, 0x3f, 0xf4, 0xa3, 0x54,
|
||||
0xea, 0x19, 0xa6, 0xfe, 0x70, 0x30, 0x52, 0x7e, 0xbb, 0x9c, 0x3f, 0x2d, 0x10, 0x4b, 0x43, 0x04, 0x6c, 0x03, 0xc7, 0x15, 0xa8, 0x8e, 0x20, 0xde, 0x46, 0x9a,
|
||||
0x12, 0xdf, 0xe5, 0xce, 0x08, 0x91, 0x3e, 0x74, 0xa4, 0x20, 0x78, 0x52, 0xfd, 0x4d, 0x9d, 0x5a, 0xca, 0xa2, 0xdc, 0xc8, 0x95, 0xa6, 0x69, 0x3a, 0x1a, 0x18,
|
||||
0x48, 0x76, 0x8c, 0xfa, 0x58, 0xa0, 0x0d, 0xf4, 0xbd, 0xe1, 0x05, 0x37, 0xe6, 0xbb, 0x60, 0xa1, 0x83, 0x40, 0x08, 0xeb, 0x02, 0xa6, 0x6a, 0x7a, 0x16, 0x1f,
|
||||
0xb3, 0xbb, 0x58, 0x66, 0x51, 0x79, 0xc7, 0x7a, 0xb2, 0xc2, 0x84, 0x69, 0x17, 0x4d, 0x27, 0xa7, 0xa4, 0xd9, 0xc2, 0xb4, 0x03, 0x57, 0x53, 0x44, 0xb9, 0x8f,
|
||||
0x48, 0xd2, 0x1c, 0x74, 0xd9, 0xdd, 0x9b, 0x88, 0xdc, 0xda, 0xef, 0x70, 0xee, 0xf1, 0x04, 0xdf, 0x35, 0x39, 0x22, 0x49, 0xfe, 0x22, 0x9a, 0x0a, 0x33, 0x60,
|
||||
0x49, 0xbe, 0x28, 0xfa, 0xdf, 0x71, 0xf6, 0x53, 0x87, 0x49, 0x01, 0x95, 0xfa, 0x22, 0x94, 0x7c, 0x0e, 0xf6, 0x7d, 0x7e, 0x1d, 0x41, 0x8d, 0xc9, 0x1b, 0xf9,
|
||||
0x53, 0xc9, 0xea, 0x46, 0xee, 0xb2, 0x80, 0x75, 0xe1, 0xca, 0xa4, 0x0a, 0x9c, 0x2b, 0x7b, 0x18, 0x70, 0x56, 0x22, 0x76, 0xaf, 0x4d, 0x84, 0xdd, 0xad, 0x77,
|
||||
0x0e, 0xea, 0x2e, 0xa9, 0xbf, 0x42, 0x95, 0xdf, 0x17, 0x61, 0xae, 0x46, 0x20, 0xb4, 0x57, 0xca, 0xe0, 0x78, 0x86, 0xd0, 0x51, 0xae, 0xfe, 0x51, 0x53, 0x0f,
|
||||
0x36, 0x64, 0x5f, 0xb4, 0x5c, 0xac, 0x8b, 0x63, 0xe7, 0x5d, 0x98, 0x8e, 0x65, 0xe9, 0x93, 0xe4, 0x23, 0x10, 0xa3, 0x5b, 0x3b, 0x38, 0x5b, 0x8a, 0x7c, 0xe8,
|
||||
0x60, 0x01, 0xc1, 0x0e, 0x53, 0x5e, 0x0e, 0x81, 0x33, 0x63, 0x8d, 0x53, 0x82, 0xa6, 0x7b, 0xbd, 0x02, 0x58, 0x1f, 0xe9, 0x50, 0x75, 0x24, 0x64, 0x80, 0x47,
|
||||
0x02, 0x9e, 0x42, 0x28, 0xb0, 0xa2, 0xab, 0x8e, 0xd0, 0x81, 0xcc, 0xbd, 0x22, 0x87, 0x3f, 0xe4, 0x49, 0x4d, 0x5f, 0x6a, 0x5c, 0x5a, 0x5e, 0xe5, 0x30, 0x9d,
|
||||
0x7f, 0xc5, 0xaf, 0xce, 0x6c, 0x9c, 0xaf, 0x09, 0x96, 0x1a, 0x62, 0x42, 0x61, 0xa3, 0x1d, 0x1c, 0xe6, 0xcf, 0xa8, 0x90, 0x4d, 0x05, 0x28, 0xef, 0x22, 0x81,
|
||||
0x12, 0xa7, 0x70, 0x04, 0x0a, 0x0d, 0xe5, 0xa0, 0xd8,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn block_without_chroma_size_is_an_error_not_a_panic() {
|
||||
let mut dec = Decoder::new();
|
||||
let r = dec.decode(PROFILE_FLIPPED).and_then(|_| dec.flush_result());
|
||||
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("chroma size")), "got {:?}", r.as_ref().map(|p| p.len()));
|
||||
}
|
||||
@@ -17,6 +17,16 @@ dimensions; the element type is the narrowest of u8/i8/u16/i16/i32 that holds ev
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _spec_path(arg):
|
||||
"""Resolve a CLI path argument and require it to be an existing regular file."""
|
||||
path = Path(arg).resolve()
|
||||
if not path.is_file():
|
||||
sys.exit(f"not a readable file: {arg}")
|
||||
return path
|
||||
|
||||
|
||||
root = sys.argv[1]
|
||||
files = sorted(f for f in os.listdir(root) if f.endswith(".md"))
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn parse_timecode(s: &str) -> Option<i64> {
|
||||
let df = s.contains([';', '.', ',']);
|
||||
let parts: Vec<&str> = s.split([':', ';', '.', ',']).collect();
|
||||
let [h, m, sec, f] = parts.as_slice() else { return None };
|
||||
let n = |p: &str| if (1..=3).contains(&p.len()) { p.parse::<i64>().ok() } else { None };
|
||||
let n = |p: &str| if (1..=3).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_digit()) { p.parse::<i64>().ok() } else { None };
|
||||
Some(fields_to_frames(n(h)?, n(m)?, n(sec)?, n(f)?, RATE, df))
|
||||
}
|
||||
|
||||
@@ -484,6 +484,14 @@ mod tests {
|
||||
Cue { start: RATE.tick_of(s), end: RATE.tick_of(e), text: text.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timecode_rejects_signed_fields() {
|
||||
assert_eq!(parse_timecode("00:00:-5:00"), None);
|
||||
assert_eq!(parse_timecode("00:+1:00:00"), None);
|
||||
assert_eq!(parse_timecode("00:00:00:"), None);
|
||||
assert_eq!(parse_timecode("00:00:01:00"), Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timecodes() {
|
||||
assert_eq!(parse_timecode("00:01:00;02"), Some(1800));
|
||||
|
||||
@@ -356,7 +356,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
||||
let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?;
|
||||
let (v, unit) = s.split_at(unit_start);
|
||||
let (vi, vf) = v.split_once('.').unwrap_or((v, ""));
|
||||
if !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 {
|
||||
if vi.is_empty() || !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 {
|
||||
return None;
|
||||
}
|
||||
let t = match unit {
|
||||
@@ -758,6 +758,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_time_without_integer_digits_is_rejected() {
|
||||
let t = tm(FrameRate::FPS_25);
|
||||
for expr in ["s", "ms", "h", "m", "t", "f", ".5s", ".5ms", ".s"] {
|
||||
assert_eq!(parse_time(expr, t), None, "{expr}");
|
||||
}
|
||||
assert_eq!(parse_time("0s", t), Some(Tick(0)));
|
||||
assert_eq!(parse_time("0.5s", t), Some(Tick(TICKS_PER_SECOND / 2)));
|
||||
assert_eq!(parse_time("1ms", t), Some(Tick(TICKS_PER_SECOND / 1000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_styles_spans_and_nesting() {
|
||||
let x = r#"<?xml version="1.0"?>
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn parse(text: &str) -> Result<Document> {
|
||||
let mut seen_cue = false;
|
||||
for b in blocks {
|
||||
let first = b[0].trim();
|
||||
if !first.contains("-->") && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) {
|
||||
if !b.iter().any(|l| l.contains("-->")) && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) {
|
||||
doc.blocks.push(b.join("\n"));
|
||||
continue;
|
||||
}
|
||||
@@ -190,6 +190,14 @@ mod tests {
|
||||
assert_eq!(split_voice(closed), (None, closed.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_prefixed_cue_id_is_a_cue() {
|
||||
let d = parse("WEBVTT\n\nNOTE 1\n00:00:01.000 --> 00:00:02.000\nhello\n").unwrap();
|
||||
assert_eq!(d.cues.len(), 1);
|
||||
assert_eq!(d.cues[0].id.as_deref(), Some("NOTE 1"));
|
||||
assert!(d.blocks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_signature() {
|
||||
let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap();
|
||||
|
||||
+119
-12
@@ -21,10 +21,15 @@ use crate::{CodecError, make_video_decoder};
|
||||
/// Most bytes read from one AAC access unit when probing for HE-AAC at open (8 channels × 6144 bits).
|
||||
const MAX_AAC_PROBE_UNIT: usize = 8 * 6144 / 8;
|
||||
|
||||
struct DecodedAudioPacket {
|
||||
samples: Vec<Vec<f32>>,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
struct AudioState {
|
||||
decoder: Option<PacketDecoder>,
|
||||
/// Decoded packets by sample index.
|
||||
packets: HashMap<usize, Arc<Vec<Vec<f32>>>>,
|
||||
packets: HashMap<usize, Arc<DecodedAudioPacket>>,
|
||||
order: Vec<usize>,
|
||||
last_decoded: Option<usize>,
|
||||
}
|
||||
@@ -92,6 +97,20 @@ fn audio_starts(file: &Mp4File, bytes: &crate::Src, ti: usize, rate: u32) -> Vec
|
||||
contiguous_starts(packets, (frame / 2).max(unit.saturating_mul(2).saturating_add(1)))
|
||||
}
|
||||
|
||||
/// Return source-rate buffer indices overlapped by a failed packet's concealment span.
|
||||
/// Packet positions are audio frames, but duration is raw track-timescale ticks
|
||||
/// treated as a frame count here; the span is clipped to the request.
|
||||
fn failed_audio_span(packet_start: i64, request_start: i64, duration: u32, request_len: usize) -> std::ops::Range<usize> {
|
||||
let begin = usize::try_from(packet_start.saturating_sub(request_start).max(0)).unwrap_or(usize::MAX).min(request_len);
|
||||
let end = if duration == 0 {
|
||||
begin
|
||||
} else {
|
||||
let last = packet_start.saturating_add(i64::from(duration) - 1).saturating_sub(request_start);
|
||||
usize::try_from((i128::from(last) + 1).max(0)).unwrap_or(usize::MAX).min(request_len)
|
||||
};
|
||||
begin..end
|
||||
}
|
||||
|
||||
/// HDR static metadata from the sample entry's `mdcv` / `clli` boxes (0 = unknown).
|
||||
fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u16, u16)>) -> Option<filmcraft_color::HdrMetadata> {
|
||||
if md.is_none() && cll.is_none() {
|
||||
@@ -366,18 +385,18 @@ impl Mp4Source {
|
||||
}
|
||||
|
||||
/// Decoded packet `i` of audio stream `a` (`ainfo` is its `AudioStreamInfo`; `st` its decoder state).
|
||||
fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result<Arc<Vec<Vec<f32>>>> {
|
||||
fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result<Arc<DecodedAudioPacket>> {
|
||||
if let Some(p) = st.packets.get(&i) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let ti = a.track;
|
||||
let track = self.file.tracks.get(ti).ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
|
||||
let entry = track.entries.first().ok_or_else(|| CodecError::Container("audio track without a sample description".into()))?;
|
||||
let sample = track.samples.get(i).ok_or_else(|| CodecError::Container("audio packet out of range".into()))?;
|
||||
track.samples.get(i).ok_or_else(|| CodecError::Container("audio packet out of range".into()))?;
|
||||
let pk_start = a.starts.get(i).copied().unwrap_or(0);
|
||||
let data = self.read(ti, i)?;
|
||||
let decoded = match &entry.codec {
|
||||
CodecConfig::Pcm(p) => decode_pcm(&data, p),
|
||||
let (samples, failed) = match &entry.codec {
|
||||
CodecConfig::Pcm(p) => (decode_pcm(&data, p), false),
|
||||
c => {
|
||||
if st.decoder.is_none() {
|
||||
st.decoder = Some(PacketDecoder::for_isobmff(c, ainfo.sample_rate)?);
|
||||
@@ -398,15 +417,15 @@ impl Mp4Source {
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64);
|
||||
let result = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64);
|
||||
st.last_decoded = Some(i);
|
||||
match r {
|
||||
Ok(v) => v,
|
||||
Err(_) => vec![vec![0.0; sample.duration as usize]; ainfo.channels.max(1) as usize],
|
||||
match result {
|
||||
Ok(samples) => (samples, false),
|
||||
Err(_) => (Vec::new(), true),
|
||||
}
|
||||
}
|
||||
};
|
||||
let p = Arc::new(decoded);
|
||||
let p = Arc::new(DecodedAudioPacket { samples, failed });
|
||||
st.packets.insert(i, p.clone());
|
||||
st.order.push(i);
|
||||
if st.order.len() > 4096 {
|
||||
@@ -476,14 +495,24 @@ impl MediaSource for Mp4Source {
|
||||
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let first = a.starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1);
|
||||
let mut i = first;
|
||||
while i < track.samples.len() {
|
||||
while let Some(sample) = track.samples.get(i) {
|
||||
let Some(&pk_start) = a.starts.get(i) else { break };
|
||||
if pk_start >= s0.saturating_add(need) {
|
||||
break;
|
||||
}
|
||||
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
|
||||
if pk.failed {
|
||||
let range = failed_audio_span(pk_start, s0, sample.duration, src.first().map_or(0, Vec::len));
|
||||
for dst in &mut src {
|
||||
if let Some(span) = dst.get_mut(range.clone()) {
|
||||
span.fill(0.0);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
for (c, dst) in src.iter_mut().enumerate() {
|
||||
let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue };
|
||||
let Some(chan) = pk.samples.get(c.min(pk.samples.len().saturating_sub(1))) else { continue };
|
||||
for (k, v) in chan.iter().enumerate() {
|
||||
let pos = pk_start.saturating_add(k as i64).saturating_sub(s0);
|
||||
if pos >= 0 && (pos as usize) < dst.len() {
|
||||
@@ -1318,3 +1347,81 @@ mod he_aac_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod corrupt_audio_tests {
|
||||
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_media::MediaSource;
|
||||
|
||||
#[test]
|
||||
fn failed_packet_span_matches_per_sample_silence_at_extreme_timestamps() {
|
||||
let min = i64::MIN;
|
||||
let max = i64::MAX;
|
||||
for start in [min, min + 1, min + 2, -10, -1, 0, 1, 10, max - 2, max - 1, max] {
|
||||
for request in [min, min + 1, min + 2, -10, -1, 0, 1, 10, max - 2, max - 1, max] {
|
||||
for duration in [0, 1, 2, 5, 128] {
|
||||
let old: Vec<_> = (0..duration)
|
||||
.map(|k| start.saturating_add(i64::from(k)).saturating_sub(request))
|
||||
.filter(|&position| (0..8).contains(&position))
|
||||
.map(|position| position as usize)
|
||||
.collect();
|
||||
let range = super::failed_audio_span(start, request, duration, 8);
|
||||
let actual: Vec<_> = (0..8).filter(|position| range.contains(position)).collect();
|
||||
let expected: Vec<_> = old.into_iter().collect::<std::collections::BTreeSet<_>>().into_iter().collect();
|
||||
assert_eq!(actual, expected, "start={start}, request={request}, duration={duration}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(super::failed_audio_span(0, 0, u32::MAX, 128), 0..128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_aac_packet_does_not_materialize_its_hostile_duration() {
|
||||
// AAC-LC, 48 kHz stereo. The empty access unit cannot decode.
|
||||
let asc = vec![0x11, 0x90];
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let track = mux.add_track(TrackConfig::new(SampleEntry::aac(asc, 2, 48_000), 48_000)).unwrap();
|
||||
mux.write_sample(track, WriteSample { data: &[], duration: 1_000_000, composition_offset: 0, is_sync: true }).unwrap();
|
||||
let source = super::Mp4Source::open("damaged.m4a", mux.finish().unwrap().into_inner().into()).unwrap();
|
||||
let audio = &source.audios[0];
|
||||
let info = &source.info.audio_streams[0];
|
||||
let mut state = audio.state.lock().unwrap();
|
||||
let packet = source.audio_packet(audio, info, &mut state, 0).unwrap();
|
||||
assert!(packet.failed);
|
||||
assert!(packet.samples.is_empty(), "failed packet must not allocate from its untrusted duration");
|
||||
drop(state);
|
||||
|
||||
let window = source.audio(0, 128, 48_000).unwrap();
|
||||
assert_eq!(window.channels.len(), 2);
|
||||
assert!(window.channels.iter().all(|ch| ch.len() == 128 && ch.iter().all(|&v| v == 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_aac_packet_silences_overlap_independent_of_request_boundary() {
|
||||
let rate = 48_000u32;
|
||||
let tone: Vec<f32> = (0..8192).map(|i| 0.3 * (i as f32 * 0.13).sin()).collect();
|
||||
let mut encoder = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(rate, 1, 64_000)).unwrap();
|
||||
let mut units = encoder.encode(&[&tone]);
|
||||
units.extend(encoder.flush());
|
||||
assert!(units.len() >= 2);
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let track = mux.add_track(TrackConfig::new(SampleEntry::aac(vec![0x11, 0x88], 1, rate), rate)).unwrap();
|
||||
mux.write_sample(track, WriteSample { data: &units[0], duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
|
||||
mux.write_sample(track, WriteSample { data: &units[1], duration: 400, composition_offset: 0, is_sync: true }).unwrap();
|
||||
mux.write_sample(track, WriteSample { data: &[], duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
|
||||
let source = super::Mp4Source::open("overlap.m4a", mux.finish().unwrap().into_inner().into()).unwrap();
|
||||
// The failed packet starts at 1024 + 400 = 1424, after 124 frames of the crossing read.
|
||||
let crossing = source.audio(1300, 256, rate).unwrap();
|
||||
let isolated = source.audio(1424, 132, rate).unwrap();
|
||||
let prior_packet_len = {
|
||||
let audio = &source.audios[0];
|
||||
let info = &source.info.audio_streams[0];
|
||||
let mut state = audio.state.lock().unwrap();
|
||||
source.audio_packet(audio, info, &mut state, 1).unwrap().samples.first().map_or(0, Vec::len)
|
||||
};
|
||||
assert!(prior_packet_len > 400, "valid packet must overlap the failed packet");
|
||||
assert!(crossing.channels[0][..124].iter().any(|v| v.abs() > 0.001));
|
||||
assert!(isolated.channels[0].iter().all(|&v| v == 0.0));
|
||||
assert_eq!(&crossing.channels[0][124..], &isolated.channels[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ for limited- and full-range files.
|
||||
| ARRI LogC4 | ARRI Wide Gamut 4 | ARRI, *ARRI LogC4 Logarithmic Color Space — Specification* (2022) | CV | a = (2¹⁸ − 16)/117.45, b = 928/1023, c = 95/1023, s = 7·ln2·2^(7 − 14c/b)/(a·b), t = (2^(6 − 14c/b) − 64)/a; y ≥ 0: x = (2^(14(y − c)/b + 6) − 64)/a; else x = y·s + t |
|
||||
| Apple Log | BT.2020 | Apple, *Apple Log Profile* white paper (2023) | IRE | R0 = −0.05641088, Rt = 0.01, c = 47.28711236, β = 0.00964052, γ = 0.08550479, δ = 0.69336945; P ≥ c(Rt − R0)²: x = 2^((P − δ)/γ) − β; else x = √(P/c) + R0 |
|
||||
| DJI D-Log | D-Gamut | DJI, *White Paper on D-Log and D-Gamut of DJI Cinema Color System* (2017) | CV | y ≤ 0.14: x = (y − 0.0929)/6.025; else x = (10^(3.89616y − 2.27752) − 0.0108)/0.9892 |
|
||||
| Fujifilm F-Log | F-Gamut (= BT.2020 primaries) | Fujifilm, *GFX ETERNA Technical Guide – White Paper* v1.01 (2026), F-Log conversion formula (same as the F-Log data sheet) | CV | y ≥ cut2: x = 10^((y − d)/c)/a − b/a; else x = (y − f)/e; a 0.555556, b 0.009468, c 0.344676, d 0.790453, e 8.735631, f 0.092864, cut1 0.00089, cut2 0.100537775223865 |
|
||||
| Fujifilm F-Log2 / F-Log2 C | F-Gamut / F-Gamut C (R 0.7347, 0.2653; G 0.0263, 0.9737; B 0.1173, −0.0224) | same document, F-Log2 C / F-Log2 conversion formula | CV | same form; a 5.555556, b 0.064829, c 0.245281, d 0.384316, e 8.799461, f 0.092864, cut1 0.000889, cut2 0.100686685370811 |
|
||||
|
||||
The encoders are the exact inverses given in the same documents. DJI **D-Log M** is not
|
||||
implemented: DJI has not published its formula (only conversion LUTs), and we do not derive curves
|
||||
|
||||
+15
-1
@@ -296,7 +296,11 @@ pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
|
||||
|
||||
/// Parse `#rrggbb` / `#rrggbbaa` into 0..1 floats.
|
||||
pub fn parse_hex(s: &str) -> Option<[f32; 4]> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| v as f32 / 255.0);
|
||||
match s.len() {
|
||||
6 => Some([b(0)?, b(2)?, b(4)?, 1.0]),
|
||||
@@ -318,6 +322,16 @@ pub fn to_hex(c: [f32; 4]) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_hex_rejects_signs() {
|
||||
assert_eq!(parse_hex("#+f+f+f"), None);
|
||||
assert_eq!(parse_hex("#-f-f-f"), None);
|
||||
assert_eq!(parse_hex("##0f0f0f"), None);
|
||||
assert_eq!(parse_hex("#0f0f0"), None);
|
||||
assert!(parse_hex("#0f0f0f").is_some());
|
||||
assert!(parse_hex("0f0f0f80").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ycbcr_roundtrip() {
|
||||
for m in [Matrix::Bt601, Matrix::Bt709, Matrix::Bt2020Ncl] {
|
||||
|
||||
+61
-2
@@ -42,11 +42,25 @@ pub enum LogCurve {
|
||||
AppleLog,
|
||||
/// DJI D-Log.
|
||||
DLog,
|
||||
/// Fujifilm F-Log.
|
||||
FLog,
|
||||
/// Fujifilm F-Log2 (also the curve of F-Log2 C).
|
||||
FLog2,
|
||||
}
|
||||
|
||||
impl LogCurve {
|
||||
pub const ALL: [LogCurve; 8] =
|
||||
[LogCurve::SLog3, LogCurve::VLog, LogCurve::CLog2, LogCurve::CLog3, LogCurve::LogC3, LogCurve::LogC4, LogCurve::AppleLog, LogCurve::DLog];
|
||||
pub const ALL: [LogCurve; 10] = [
|
||||
LogCurve::SLog3,
|
||||
LogCurve::VLog,
|
||||
LogCurve::CLog2,
|
||||
LogCurve::CLog3,
|
||||
LogCurve::LogC3,
|
||||
LogCurve::LogC4,
|
||||
LogCurve::AppleLog,
|
||||
LogCurve::DLog,
|
||||
LogCurve::FLog,
|
||||
LogCurve::FLog2,
|
||||
];
|
||||
|
||||
pub fn domain(self) -> Domain {
|
||||
match self {
|
||||
@@ -65,6 +79,8 @@ impl LogCurve {
|
||||
LogCurve::LogC4 => "ARRI LogC4",
|
||||
LogCurve::AppleLog => "Apple Log",
|
||||
LogCurve::DLog => "DJI D-Log",
|
||||
LogCurve::FLog => "Fujifilm F-Log",
|
||||
LogCurve::FLog2 => "Fujifilm F-Log2",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +95,8 @@ impl LogCurve {
|
||||
LogCurve::LogC4 => logc4_encode(x),
|
||||
LogCurve::AppleLog => apple_log_encode(x),
|
||||
LogCurve::DLog => dlog_encode(x),
|
||||
LogCurve::FLog => fujifilm_encode(&FLOG, x),
|
||||
LogCurve::FLog2 => fujifilm_encode(&FLOG2, x),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +111,8 @@ impl LogCurve {
|
||||
LogCurve::LogC4 => logc4_decode(y),
|
||||
LogCurve::AppleLog => apple_log_decode(y),
|
||||
LogCurve::DLog => dlog_decode(y),
|
||||
LogCurve::FLog => fujifilm_decode(&FLOG, y),
|
||||
LogCurve::FLog2 => fujifilm_decode(&FLOG2, y),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +282,35 @@ pub fn dlog_decode(y: f64) -> f64 {
|
||||
if y <= 0.14 { (y - 0.0929) / 6.025 } else { (10f64.powf(3.896_16 * y - 2.277_52) - 0.0108) / 0.9892 }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Fujifilm F-Log / F-Log2
|
||||
// Fujifilm, "GFX ETERNA Technical Guide - White Paper" v1.01 (2026), F-Log / F-Log2 C / F-Log2
|
||||
// conversion formulas (the same as the F-Log and F-Log2 data sheets). `in` is scene-linear
|
||||
// reflection, `out` 10-bit code value / 1023: 0 % → 95, 18 % → 470 (F-Log) / 400 (F-Log2).
|
||||
|
||||
/// a, b, c, d, e, f, cut1 (encoding: scene-linear), cut2 (decoding: signal).
|
||||
struct Fujifilm {
|
||||
a: f64,
|
||||
b: f64,
|
||||
c: f64,
|
||||
d: f64,
|
||||
e: f64,
|
||||
f: f64,
|
||||
cut1: f64,
|
||||
cut2: f64,
|
||||
}
|
||||
|
||||
const FLOG: Fujifilm =
|
||||
Fujifilm { a: 0.555_556, b: 0.009_468, c: 0.344_676, d: 0.790_453, e: 8.735_631, f: 0.092_864, cut1: 0.000_89, cut2: 0.100_537_775_223_865 };
|
||||
const FLOG2: Fujifilm =
|
||||
Fujifilm { a: 5.555_556, b: 0.064_829, c: 0.245_281, d: 0.384_316, e: 8.799_461, f: 0.092_864, cut1: 0.000_889, cut2: 0.100_686_685_370_811 };
|
||||
|
||||
fn fujifilm_encode(k: &Fujifilm, x: f64) -> f64 {
|
||||
if x >= k.cut1 { k.c * (k.a * x + k.b).log10() + k.d } else { k.e * x + k.f }
|
||||
}
|
||||
fn fujifilm_decode(k: &Fujifilm, y: f64) -> f64 {
|
||||
if y >= k.cut2 { 10f64.powf((y - k.d) / k.c) / k.a - k.b / k.a } else { (y - k.f) / k.e }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -317,6 +366,16 @@ mod tests {
|
||||
// DJI D-Log: 0 → 0.0929, 18 % → 0.398 (white paper).
|
||||
close(LogCurve::DLog.encode(0.0), 0.0929, 1e-9, "D-Log 0");
|
||||
close(LogCurve::DLog.encode(0.18), 0.398, 0.002, "D-Log 18%");
|
||||
// Fujifilm (white paper code-value table): 0 % → 95, 18 % → 470 / 400, 90 % → 705 / 570.
|
||||
close(cv(LogCurve::FLog, 0.0), 95.0, 0.5, "F-Log 0%");
|
||||
close(cv(LogCurve::FLog, 0.18), 470.0, 0.5, "F-Log 18%");
|
||||
close(cv(LogCurve::FLog, 0.9), 705.0, 0.6, "F-Log 90%");
|
||||
close(cv(LogCurve::FLog2, 0.0), 95.0, 0.5, "F-Log2 0%");
|
||||
close(cv(LogCurve::FLog2, 0.18), 400.0, 0.5, "F-Log2 18%");
|
||||
close(cv(LogCurve::FLog2, 0.9), 570.0, 0.5, "F-Log2 90%");
|
||||
// the decoding cut is where the encoding's two pieces meet (within the published rounding)
|
||||
close(LogCurve::FLog.encode(0.000_89), 0.100_537_775_223_865, 2e-4, "F-Log cut");
|
||||
close(LogCurve::FLog2.encode(0.000_889), 0.100_686_685_370_811, 2e-4, "F-Log2 cut");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -34,6 +34,8 @@ pub enum Gamut {
|
||||
Awg4,
|
||||
/// DJI D-Gamut.
|
||||
DGamut,
|
||||
/// Fujifilm F-Gamut C (F-Log2 C). F-Gamut itself has the BT.2020 primaries.
|
||||
FGamutC,
|
||||
}
|
||||
|
||||
impl Gamut {
|
||||
@@ -51,6 +53,7 @@ impl Gamut {
|
||||
Gamut::Awg3 => [[0.6840, 0.3130], [0.2210, 0.8480], [0.0861, -0.1020]],
|
||||
Gamut::Awg4 => [[0.7347, 0.2653], [0.1424, 0.8576], [0.0991, -0.0308]],
|
||||
Gamut::DGamut => [[0.71, 0.31], [0.21, 0.88], [0.09, -0.08]],
|
||||
Gamut::FGamutC => [[0.7347, 0.2653], [0.0263, 0.9737], [0.1173, -0.0224]],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,12 +168,18 @@ pub enum ColorSpace {
|
||||
/// Apple Log (BT.2020 primaries).
|
||||
AppleLog,
|
||||
DLogDGamut,
|
||||
/// Fujifilm F-Log / F-Gamut (BT.2020 primaries).
|
||||
FLogFGamut,
|
||||
/// Fujifilm F-Log2 / F-Gamut (BT.2020 primaries).
|
||||
FLog2FGamut,
|
||||
/// Fujifilm F-Log2 C / F-Gamut C.
|
||||
FLog2CFGamutC,
|
||||
/// Linear light with BT.709 primaries (EXR-style float sources).
|
||||
Linear709,
|
||||
}
|
||||
|
||||
impl ColorSpace {
|
||||
pub const ALL: [ColorSpace; 16] = [
|
||||
pub const ALL: [ColorSpace; 19] = [
|
||||
ColorSpace::Rec709,
|
||||
ColorSpace::Srgb,
|
||||
ColorSpace::Rec2020,
|
||||
@@ -186,6 +195,9 @@ impl ColorSpace {
|
||||
ColorSpace::LogC4Awg4,
|
||||
ColorSpace::AppleLog,
|
||||
ColorSpace::DLogDGamut,
|
||||
ColorSpace::FLogFGamut,
|
||||
ColorSpace::FLog2FGamut,
|
||||
ColorSpace::FLog2CFGamutC,
|
||||
ColorSpace::Linear709,
|
||||
];
|
||||
|
||||
@@ -207,6 +219,9 @@ impl ColorSpace {
|
||||
ColorSpace::LogC4Awg4 => "logc4-awg4",
|
||||
ColorSpace::AppleLog => "applelog",
|
||||
ColorSpace::DLogDGamut => "dlog-dgamut",
|
||||
ColorSpace::FLogFGamut => "flog-fgamut",
|
||||
ColorSpace::FLog2FGamut => "flog2-fgamut",
|
||||
ColorSpace::FLog2CFGamutC => "flog2c-fgamutc",
|
||||
ColorSpace::Linear709 => "linear709",
|
||||
}
|
||||
}
|
||||
@@ -228,6 +243,9 @@ impl ColorSpace {
|
||||
ColorSpace::LogC4Awg4 => "ARRI LogC4 / ARRI Wide Gamut 4",
|
||||
ColorSpace::AppleLog => "Apple Log / Rec. 2020",
|
||||
ColorSpace::DLogDGamut => "DJI D-Log / D-Gamut",
|
||||
ColorSpace::FLogFGamut => "Fujifilm F-Log / F-Gamut",
|
||||
ColorSpace::FLog2FGamut => "Fujifilm F-Log2 / F-Gamut",
|
||||
ColorSpace::FLog2CFGamutC => "Fujifilm F-Log2 C / F-Gamut C",
|
||||
ColorSpace::Linear709 => "Linear (Rec. 709 primaries)",
|
||||
}
|
||||
}
|
||||
@@ -242,7 +260,9 @@ impl ColorSpace {
|
||||
pub fn gamut(self) -> Gamut {
|
||||
match self {
|
||||
ColorSpace::Rec709 | ColorSpace::Srgb | ColorSpace::Linear709 => Gamut::Bt709,
|
||||
ColorSpace::Rec2020 | ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg | ColorSpace::AppleLog => Gamut::Bt2020,
|
||||
ColorSpace::Rec2020 | ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg | ColorSpace::AppleLog | ColorSpace::FLogFGamut | ColorSpace::FLog2FGamut => {
|
||||
Gamut::Bt2020
|
||||
}
|
||||
ColorSpace::P3D65 => Gamut::P3D65,
|
||||
ColorSpace::SLog3SGamut3Cine => Gamut::SGamut3Cine,
|
||||
ColorSpace::SLog3SGamut3 => Gamut::SGamut3,
|
||||
@@ -251,6 +271,7 @@ impl ColorSpace {
|
||||
ColorSpace::LogC3Awg3 => Gamut::Awg3,
|
||||
ColorSpace::LogC4Awg4 => Gamut::Awg4,
|
||||
ColorSpace::DLogDGamut => Gamut::DGamut,
|
||||
ColorSpace::FLog2CFGamutC => Gamut::FGamutC,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +290,8 @@ impl ColorSpace {
|
||||
ColorSpace::LogC4Awg4 => Curve::Log(LogCurve::LogC4),
|
||||
ColorSpace::AppleLog => Curve::Log(LogCurve::AppleLog),
|
||||
ColorSpace::DLogDGamut => Curve::Log(LogCurve::DLog),
|
||||
ColorSpace::FLogFGamut => Curve::Log(LogCurve::FLog),
|
||||
ColorSpace::FLog2FGamut | ColorSpace::FLog2CFGamutC => Curve::Log(LogCurve::FLog2),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
|
||||
//! truncation at (nearly) every length and random mutation of encoder-made frames must never
|
||||
//! panic or run away (errors are fine). No ffmpeg needed.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with, probe};
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn seeds() -> Vec<Vec<u8>> {
|
||||
let mut v = Vec::new();
|
||||
for (profile, depth, w, h) in
|
||||
[(Profile::Lb, 8, 64u32, 48u32), (Profile::Hq, 8, 48, 32), (Profile::Hqx, 10, 64, 32), (Profile::Hqx, 12, 48, 32), (Profile::R444, 10, 48, 32)]
|
||||
{
|
||||
let mut cfg = EncoderConfig::new(profile, w, h);
|
||||
cfg.bit_depth = depth;
|
||||
let chroma = cfg.chroma();
|
||||
let mut f = Frame::new(w, h, chroma, depth, false);
|
||||
for (i, s) in f.y.iter_mut().enumerate() {
|
||||
*s = ((16 + (i * 37) % 200) as u16) << (depth - 8);
|
||||
}
|
||||
if chroma != ChromaFormat::Yuv444 {
|
||||
f.cb.fill(128 << (depth - 8));
|
||||
f.cr.fill(100 << (depth - 8));
|
||||
}
|
||||
if let Ok(mut enc) = Encoder::with_config(cfg)
|
||||
&& let Ok(d) = enc.encode(&f)
|
||||
{
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn run(d: &[u8], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
let _ = probe(d);
|
||||
for opts in [DecodeOptions::default(), DecodeOptions { threads: true, spec_dequant: true }] {
|
||||
let _ = decode_frame_with(d, &opts);
|
||||
}
|
||||
}));
|
||||
assert!(res.is_ok(), "panic: {label}");
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_frames_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let seeds = seeds();
|
||||
assert!(!seeds.is_empty());
|
||||
for (si, seed) in seeds.iter().enumerate() {
|
||||
let stride = (seed.len() / 400).max(1);
|
||||
for n in (0..seed.len()).step_by(stride) {
|
||||
run(&seed[..n], &format!("seed {si} truncate {n}"));
|
||||
}
|
||||
// The header (and the start of the scan-line index): every byte to extreme values, every bit.
|
||||
for p in 0..seed.len().min(0x300) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[p] = v;
|
||||
run(&d, &format!("seed {si} byte {p:#x}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[p] ^= 1 << b;
|
||||
run(&d, &format!("seed {si} flip {p:#x}.{b}"));
|
||||
}
|
||||
}
|
||||
// Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C).
|
||||
for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] {
|
||||
let mut d = seed.clone();
|
||||
if let Some(s) = d.get_mut(off..off + 2) {
|
||||
s.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
run(&d, &format!("seed {si} u16 {off:#x}={v:#x}"));
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
|
||||
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1500);
|
||||
for i in 0..iters {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let p = rng.below(d.len());
|
||||
if rng.below(2) == 0 {
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run(&d, &format!("seed {si} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,18 @@ prefix code.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
|
||||
def _spec_path(arg):
|
||||
"""Resolve a CLI path argument and require it to be an existing regular file."""
|
||||
path = Path(arg).resolve()
|
||||
if not path.is_file():
|
||||
sys.exit(f"not a readable file: {arg}")
|
||||
return path
|
||||
|
||||
|
||||
text = open(_spec_path(sys.argv[1]), encoding="utf-8").read()
|
||||
lines = text.split("\n")
|
||||
|
||||
|
||||
|
||||
@@ -87,6 +87,50 @@ fn aaf_embedded_trimmed_audio_round_trips_sample_exact() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_aaf_and_omf_import_leave_existing_media_files_intact() {
|
||||
for (format, extension) in [(filmcraft_interchange::Format::Aaf, "aaf"), (filmcraft_interchange::Format::Omf, "omf")] {
|
||||
let (mut s, _items, seq, dir) = setup(&format!("embedded-collision-{extension}"));
|
||||
let path = dir.join(format!("cut.{extension}"));
|
||||
let path_text = path.to_string_lossy().into_owned();
|
||||
let command = if format == filmcraft_interchange::Format::Aaf { "file.exportAaf" } else { "file.exportOmf" };
|
||||
s.execute(command, json!({"path": path_text, "audio": "embedded", "sequence": seq.0})).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let opts = filmcraft_interchange::ImportOptions { base_dir: Some(dir.to_string_lossy().into_owned()), name: Some("cut".into()), ..Default::default() };
|
||||
let (_, extracted, _) = if format == filmcraft_interchange::Format::Aaf {
|
||||
filmcraft_interchange::aaf::import(&bytes, &opts).unwrap()
|
||||
} else {
|
||||
filmcraft_interchange::omf::import(&bytes, &opts).unwrap()
|
||||
};
|
||||
let first = extracted.first().expect("embedded audio");
|
||||
let original = b"ORIGINAL USER DATA";
|
||||
std::fs::create_dir_all(std::path::Path::new(&first.path).parent().unwrap()).unwrap();
|
||||
std::fs::write(&first.path, original).unwrap();
|
||||
|
||||
let response = s.execute("file.import", json!({"paths": [path_text]})).unwrap();
|
||||
assert!(std::fs::read(&first.path).unwrap().as_slice() == original, "{extension}: existing file was overwritten");
|
||||
let imported = s.project.sequence(imported_seq(&response)).unwrap();
|
||||
let new_path = path_of(&s, imported.audio_tracks[0].items[0].item);
|
||||
assert_ne!(new_path, first.path, "{extension}: imported audio still points at the existing file");
|
||||
assert!(new_path.ends_with(" (2).wav"), "{extension}: {new_path}");
|
||||
assert!(std::fs::read(&new_path).unwrap() == first.wav, "{extension}: imported audio was not extracted to the new path");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let protected = dir.join("protected.wav");
|
||||
std::fs::write(&protected, original).unwrap();
|
||||
std::fs::remove_file(&first.path).unwrap();
|
||||
std::os::unix::fs::symlink(&protected, &first.path).unwrap();
|
||||
let again = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
assert!(std::fs::read(&protected).unwrap().as_slice() == original, "{extension}: symlink target was overwritten");
|
||||
let imported_again = s.project.sequence(imported_seq(&again)).unwrap();
|
||||
let third_path = path_of(&s, imported_again.audio_tracks[0].items[0].item);
|
||||
assert!(third_path.ends_with(" (3).wav"), "{extension}: {third_path}");
|
||||
assert!(std::fs::read(&third_path).unwrap() == first.wav, "{extension}: the third extraction is not the embedded audio");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_separate_aiff_breakout_and_linked() {
|
||||
let (mut s, items, seq, dir) = setup("aaf-separate");
|
||||
|
||||
@@ -82,7 +82,11 @@ fn parse_color(v: &Value) -> Option<[u8; 4]> {
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let h = v.as_str()?.trim_start_matches('#');
|
||||
let h = v.as_str()?;
|
||||
let h = h.strip_prefix('#').unwrap_or(h);
|
||||
if !h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let b = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok();
|
||||
match h.len() {
|
||||
6 => Some([b(0)?, b(2)?, b(4)?, 255]),
|
||||
|
||||
@@ -3077,6 +3077,13 @@ fn build() -> Vec<CommandSpec> {
|
||||
let (keyframes, v) = {
|
||||
let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?;
|
||||
let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?;
|
||||
// the parameter's hard range, which `effects.list` publishes: a value outside it
|
||||
// (a factor where a percentage is meant, #2679) is refused, not saved
|
||||
if let (Some(filmcraft_project::ParamKind::Float { min, max, .. }), ParamValue::Float(x)) = (def.map(|d| &d.kind), &v)
|
||||
&& !(*min..=*max).contains(x)
|
||||
{
|
||||
return Err(bad("effects.setParam", format!("`{pid}` must be between {min} and {max}, got {x}")));
|
||||
}
|
||||
let keyframes = if keyframe {
|
||||
// the stopwatch and the value in one step
|
||||
prm.put_keyframe(mt, v.clone());
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO, AAF, OMF) ↔ the session's project.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use filmcraft_interchange::{ExportOptions, Format, ImportOptions};
|
||||
@@ -24,6 +26,32 @@ fn is_file_media(item: &filmcraft_project::ProjectItem) -> bool {
|
||||
matches!(&item.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::File { .. }))
|
||||
}
|
||||
|
||||
/// Write embedded audio without replacing an occupied path; return the path used.
|
||||
fn write_embedded_audio(s: &Session, path: &str, wav: &[u8]) -> Result<String> {
|
||||
let original = Path::new(path);
|
||||
let stem = original.file_stem().ok_or_else(|| EngineError::Other(format!("{path}: missing audio file name")))?;
|
||||
for number in 1..=1_000 {
|
||||
let candidate = if number == 1 {
|
||||
original.to_path_buf()
|
||||
} else {
|
||||
let mut name = stem.to_os_string();
|
||||
name.push(format!(" ({number})"));
|
||||
if let Some(extension) = original.extension() {
|
||||
name.push(".");
|
||||
name.push(extension);
|
||||
}
|
||||
original.with_file_name(name)
|
||||
};
|
||||
let candidate_text = candidate.to_string_lossy();
|
||||
match s.services.write_file_new(&candidate_text, wav) {
|
||||
Ok(()) => return Ok(candidate_text.into_owned()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) => return Err(EngineError::Other(format!("{candidate_text}: {e}"))),
|
||||
}
|
||||
}
|
||||
Err(EngineError::Other(format!("{path}: no unused embedded-audio filename after 1000 attempts")))
|
||||
}
|
||||
|
||||
/// Import a document: merge its bins, media and sequences into the project (one undo step), then
|
||||
/// link each media file that exists on disk. Returns the new sequences.
|
||||
pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result<Value> {
|
||||
@@ -37,12 +65,17 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
||||
// AAF / OMF may embed audio: write it next to the document (the media items point there)
|
||||
Format::Aaf | Format::Omf => {
|
||||
let r = if format == Format::Aaf { filmcraft_interchange::aaf::import(bytes, &opts) } else { filmcraft_interchange::omf::import(bytes, &opts) };
|
||||
let (fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let (mut fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
for m in extracted {
|
||||
if let Some(dir) = std::path::Path::new(&m.path).parent().filter(|_| !s.services.export_in_memory()) {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let media = fragment
|
||||
.project
|
||||
.item_mut(m.item)
|
||||
.and_then(|item| item.as_media_mut())
|
||||
.ok_or_else(|| EngineError::Other(format!("{}: embedded audio has no media item", m.path)))?;
|
||||
let written_path = write_embedded_audio(s, &m.path, &m.wav)?;
|
||||
if written_path != m.path {
|
||||
media.media = MediaRef::File { path: written_path };
|
||||
}
|
||||
s.services.write_file(&m.path, &m.wav).map_err(|e| EngineError::Other(format!("{}: {e}", m.path)))?;
|
||||
}
|
||||
(fragment, report)
|
||||
}
|
||||
@@ -152,3 +185,51 @@ pub fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "report": report_json(&report)}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod embedded_audio_tests {
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::{Services, Session};
|
||||
|
||||
use super::write_embedded_audio;
|
||||
|
||||
#[derive(Default)]
|
||||
struct VirtualFiles(Mutex<HashMap<String, Vec<u8>>>);
|
||||
|
||||
impl Services for VirtualFiles {
|
||||
fn read_file(&self, path: &str) -> io::Result<Vec<u8>> {
|
||||
self.0.lock().unwrap().get(path).cloned().ok_or_else(|| io::ErrorKind::NotFound.into())
|
||||
}
|
||||
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> io::Result<()> {
|
||||
self.0.lock().unwrap().insert(path.to_owned(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file_new(&self, path: &str, data: &[u8]) -> io::Result<()> {
|
||||
let mut files = self.0.lock().unwrap();
|
||||
if files.contains_key(path) {
|
||||
return Err(io::ErrorKind::AlreadyExists.into());
|
||||
}
|
||||
files.insert(path.to_owned(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_extraction_keeps_existing_media() {
|
||||
let files = Arc::new(VirtualFiles::default());
|
||||
files.0.lock().unwrap().insert("/files/cut Audio.wav".into(), b"first".to_vec());
|
||||
files.0.lock().unwrap().insert("/files/cut Audio (2).wav".into(), b"second".to_vec());
|
||||
let session = Session::new(files.clone());
|
||||
let path = write_embedded_audio(&session, "/files/cut Audio.wav", b"new audio").unwrap();
|
||||
assert_eq!(path, "/files/cut Audio (3).wav");
|
||||
let stored = files.0.lock().unwrap();
|
||||
assert_eq!(stored["/files/cut Audio.wav"], b"first");
|
||||
assert_eq!(stored["/files/cut Audio (2).wav"], b"second");
|
||||
assert_eq!(stored["/files/cut Audio (3).wav"], b"new audio");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,14 @@ pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
pub trait Services: Send + Sync {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>>;
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>;
|
||||
/// Publish a complete new file only when the final path is unoccupied. Publication must
|
||||
/// reject a concurrent collision; a prior existence check alone is insufficient.
|
||||
/// Return `ErrorKind::AlreadyExists` for an occupied final path so the importer can retry
|
||||
/// with a numbered name; return other errors without publishing a partial file.
|
||||
/// Hosts without this operation fail closed.
|
||||
fn write_file_new(&self, _path: &str, _data: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "exclusive file creation is unavailable on this host"))
|
||||
}
|
||||
/// Size of a file in bytes (an error when it is missing). Hosts should override this: the
|
||||
/// default reads the whole file.
|
||||
fn file_size(&self, path: &str) -> std::io::Result<u64> {
|
||||
@@ -159,6 +167,23 @@ impl Services for FsServices {
|
||||
// Atomic + durable: temp file in the same directory, fsync, rename, fsync the directory.
|
||||
filmcraft_format::atomic_write(std::path::Path::new(path), data)
|
||||
}
|
||||
fn write_file_new(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
let path = std::path::Path::new(path);
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| std::path::Path::new("."));
|
||||
std::fs::create_dir_all(dir).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
std::io::Error::new(std::io::ErrorKind::NotADirectory, format!("{} is not a directory: {error}", dir.display()))
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})?;
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(_) => return Err(std::io::ErrorKind::AlreadyExists.into()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
filmcraft_format::atomic_write_new(path, data)
|
||||
}
|
||||
fn file_size(&self, path: &str) -> std::io::Result<u64> {
|
||||
let m = std::fs::metadata(path)?;
|
||||
if m.is_file() { Ok(m.len()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("{path} is not a file"))) }
|
||||
|
||||
@@ -128,3 +128,27 @@ fn ultra_key_setting_writes_aggressive_parameters() {
|
||||
assert_eq!(e.param("choke").unwrap().value.as_f64(), Some(10.0));
|
||||
assert_eq!(e.param("contrast").unwrap().value.as_f64(), Some(10.0));
|
||||
}
|
||||
|
||||
/// #2679: a value outside a parameter's range (the `min`/`max` `effects.list` publishes) is
|
||||
/// refused with the range, not saved: gamma 1.1 meant as a factor turned the frame black.
|
||||
#[test]
|
||||
fn set_param_refuses_values_outside_the_range() {
|
||||
let mut s = demo();
|
||||
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
s.execute("effects.apply", json!({"clips": [clip], "effect": "levels"})).unwrap();
|
||||
s.execute("effects.apply", json!({"clips": [clip], "effect": "lumetri"})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
let undo = s.history.undo.len();
|
||||
for (effect, param, value) in
|
||||
[("levels", "gamma", 1.1), ("lumetri", "saturation", 500.0), ("lumetri", "vibrance", -300.0), ("opacity", "opacity", 250.0), ("motion", "scale", -50.0)]
|
||||
{
|
||||
let e = s.execute("effects.setParam", json!({"clip": clip, "effect": effect, "param": param, "value": value})).unwrap_err().to_string();
|
||||
assert!(e.contains("must be between"), "{effect}.{param} = {value}: {e}");
|
||||
}
|
||||
assert_eq!(s.project.to_json(), before, "nothing was saved");
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
// the ends of the range and values inside it still go in
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": "levels", "param": "gamma", "value": 110})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": "opacity", "param": "opacity", "value": 100})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": "opacity", "param": "opacity", "value": 0})).unwrap();
|
||||
}
|
||||
|
||||
@@ -17,5 +17,8 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tempfile = "3.27"
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-time = { workspace = true }
|
||||
|
||||
@@ -46,8 +46,37 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort directory fsync (persists the rename on POSIX; not possible on Windows, where
|
||||
/// `MoveFileEx` with write-through semantics is already durable enough).
|
||||
/// Publish a complete new file without replacing an occupied final path, including a symlink.
|
||||
/// Bytes are staged and synced in the destination directory before no-clobber publication.
|
||||
/// A write failure leaves no partial final file; temporary-file cleanup is best effort.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn atomic_write_new(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
atomic_write_new_with(path, |file| file.write_all(data).and_then(|_| file.sync_all()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn atomic_write_new(_path: &Path, _data: &[u8]) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "native exclusive file creation is unavailable in the browser"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn atomic_write_new_with(path: &Path, write: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let dir = parent_dir(path);
|
||||
#[cfg(unix)]
|
||||
let mut staged = {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Match ordinary file-creation permissions after the process umask.
|
||||
tempfile::Builder::new().permissions(fs::Permissions::from_mode(0o666)).tempfile_in(&dir)?
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut staged = tempfile::NamedTempFile::new_in(&dir)?;
|
||||
write(staged.as_file_mut())?;
|
||||
let _published = staged.persist_noclobber(path).map_err(|e| e.error)?;
|
||||
sync_dir(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort directory sync; directory handles are not uniformly readable or syncable.
|
||||
fn sync_dir(dir: &Path) {
|
||||
if let Ok(d) = File::open(dir) {
|
||||
let _ = d.sync_all();
|
||||
@@ -92,6 +121,66 @@ mod tests {
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn new_file_is_published_complete_without_clobbering_or_temp_leaks() {
|
||||
let d = tmpdir("new");
|
||||
let p = d.join("audio.wav");
|
||||
atomic_write_new(&p, b"complete").unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"complete");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let reference = d.join("normal-create");
|
||||
OpenOptions::new().write(true).create_new(true).open(&reference).unwrap();
|
||||
assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, fs::metadata(&reference).unwrap().permissions().mode() & 0o777);
|
||||
fs::remove_file(reference).unwrap();
|
||||
}
|
||||
assert_eq!(atomic_write_new(&p, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(fs::read(&p).unwrap(), b"complete");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = d.join("link.wav");
|
||||
std::os::unix::fs::symlink(&p, &link).unwrap();
|
||||
assert_eq!(atomic_write_new(&link, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(fs::read(&p).unwrap(), b"complete");
|
||||
}
|
||||
let expected = if cfg!(unix) { 2 } else { 1 };
|
||||
assert_eq!(fs::read_dir(&d).unwrap().count(), expected, "staging file remained after publication");
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_file_can_publish_in_a_directory_without_read_permission() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = tmpdir("write-only-directory");
|
||||
let original_permissions = fs::metadata(&d).unwrap().permissions();
|
||||
fs::set_permissions(&d, fs::Permissions::from_mode(0o300)).unwrap();
|
||||
let p = d.join("audio.wav");
|
||||
let result = atomic_write_new(&p, b"complete");
|
||||
fs::set_permissions(&d, original_permissions).unwrap();
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(fs::read(&p).unwrap(), b"complete");
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn failed_staging_leaves_no_partial_destination() {
|
||||
let d = tmpdir("failed-new");
|
||||
let p = d.join("audio.wav");
|
||||
let error = atomic_write_new_with(&p, |file| {
|
||||
file.write_all(b"partial")?;
|
||||
Err(io::Error::other("simulated full disk"))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.to_string(), "simulated full disk");
|
||||
assert!(!p.exists());
|
||||
assert_eq!(fs::read_dir(&d).unwrap().count(), 0);
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_directory_is_an_error() {
|
||||
let d = tmpdir("missing");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Auto-save file naming and rotation (Premiere behaviour).
|
||||
//!
|
||||
//! Auto-saves go to an `Auto-Save` folder next to the project, named
|
||||
//! `<project name>-YYYY-MM-DD_HH-MM-SS.fcproj` (local wall-clock time). After each write the oldest
|
||||
//! files of that project beyond the "Maximum Project Versions" limit are deleted. Only files whose
|
||||
//! name is exactly `<name>-<timestamp>.fcproj` are ever touched.
|
||||
//! `<project name>-YYYY-MM-DD_HH-MM-SS-<unix seconds>.fcproj` (local wall-clock time, then the real
|
||||
//! time that orders the files). After each write the oldest files of that project beyond the "Maximum
|
||||
//! Project Versions" limit are deleted. Only files whose name is exactly `<name>-<timestamp>.fcproj`
|
||||
//! or `<name>-<timestamp>-<unix seconds>.fcproj` are ever touched.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -55,9 +56,10 @@ pub fn display_time(unix: i64, offset_secs: i32) -> String {
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}")
|
||||
}
|
||||
|
||||
/// `<stem>-YYYY-MM-DD_HH-MM-SS.fcproj`.
|
||||
/// `<stem>-YYYY-MM-DD_HH-MM-SS-<unix seconds>.fcproj`. The local stamp is for people; the unix time
|
||||
/// orders the files (the local clock can go back, or repeat an hour, when daylight saving time ends).
|
||||
pub fn auto_save_name(name: &str, unix: i64, offset_secs: i32) -> String {
|
||||
format!("{}-{}.fcproj", file_stem(name), timestamp(unix, offset_secs))
|
||||
format!("{}-{}-{unix}.fcproj", file_stem(name), timestamp(unix, offset_secs))
|
||||
}
|
||||
|
||||
fn is_stamp(s: &str) -> bool {
|
||||
@@ -70,20 +72,33 @@ fn is_stamp(s: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Auto-saves of project `name` in `dir`, oldest first.
|
||||
/// Parse `YYYY-MM-DD_HH-MM-SS` or `YYYY-MM-DD_HH-MM-SS-<unix>` into the stamp and the unix time
|
||||
/// (`None` for files written before the unix time was part of the name).
|
||||
fn parse_stamp(s: &str) -> Option<(&str, Option<i64>)> {
|
||||
if is_stamp(s) {
|
||||
return Some((s, None));
|
||||
}
|
||||
let stamp = s.get(..19).filter(|st| is_stamp(st))?;
|
||||
let digits = s.get(19..)?.strip_prefix('-')?;
|
||||
let unix: i64 = digits.parse().ok()?;
|
||||
(unix.to_string() == digits).then_some((stamp, Some(unix)))
|
||||
}
|
||||
|
||||
/// Auto-saves of project `name` in `dir`, oldest first (by real time, not by the local clock; files
|
||||
/// without a unix time in their name are older than those with one).
|
||||
pub fn list_auto_saves(dir: &Path, name: &str) -> Vec<PathBuf> {
|
||||
let prefix = format!("{}-", file_stem(name));
|
||||
let Ok(rd) = fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut v: Vec<(String, PathBuf)> = rd
|
||||
let mut v: Vec<(Option<i64>, String, PathBuf)> = rd
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
let f = e.file_name().to_string_lossy().into_owned();
|
||||
let stamp = f.strip_prefix(&prefix)?.strip_suffix(".fcproj")?;
|
||||
is_stamp(stamp).then(|| (stamp.to_string(), e.path()))
|
||||
let (stamp, unix) = parse_stamp(f.strip_prefix(&prefix)?.strip_suffix(".fcproj")?)?;
|
||||
Some((unix, stamp.to_string(), e.path()))
|
||||
})
|
||||
.collect();
|
||||
v.sort();
|
||||
v.into_iter().map(|(_, p)| p).collect()
|
||||
v.into_iter().map(|(_, _, p)| p).collect()
|
||||
}
|
||||
|
||||
/// Delete the oldest auto-saves of `name` so at most `max` remain. Returns the deleted paths.
|
||||
@@ -124,12 +139,40 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn names_and_stems() {
|
||||
assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00.fcproj");
|
||||
assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00-0.fcproj");
|
||||
assert_eq!(file_stem("a/b:c"), "a_b_c");
|
||||
assert_eq!(file_stem(" "), "Untitled");
|
||||
assert_eq!(auto_save_dir(Path::new("/x/y/p.fcproj")), Path::new("/x/y/Auto-Save"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_survives_the_clock_going_back_at_the_end_of_dst() {
|
||||
let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
let t0 = 1_792_900_000;
|
||||
// 02:30 at +02:00, then 40 minutes later the clock reads 02:10 at +01:00.
|
||||
let old = write_auto_save(&d, "Cut", t0, 7200, b"old", 1).unwrap();
|
||||
let new = write_auto_save(&d, "Cut", t0 + 2400, 3600, b"new", 1).unwrap();
|
||||
assert!(new.exists(), "the newest auto-save was deleted by rotation");
|
||||
assert!(!old.exists(), "the oldest auto-save should be rotated out");
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_wall_clock_hour_does_not_overwrite_earlier_saves() {
|
||||
let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst2-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
let t0 = 1_792_900_000;
|
||||
// The same wall-clock second, one hour apart (+02:00 then +01:00).
|
||||
let a = write_auto_save(&d, "Cut", t0, 7200, b"first", 5).unwrap();
|
||||
let b = write_auto_save(&d, "Cut", t0 + 3600, 3600, b"second", 5).unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(fs::read(&a).unwrap(), b"first");
|
||||
assert_eq!(fs::read(&b).unwrap(), b"second");
|
||||
assert_eq!(list_auto_saves(&d, "Cut"), vec![a, b]);
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_newest_and_ignores_other_files() {
|
||||
let d = std::env::temp_dir().join(format!("filmcraft-rotate-{}", std::process::id()));
|
||||
@@ -142,8 +185,8 @@ mod tests {
|
||||
fs::write(d.join("notes.txt"), b"keep").unwrap();
|
||||
let left = list_auto_saves(&d, "Cut");
|
||||
assert_eq!(left.len(), 5);
|
||||
assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_120, 0))));
|
||||
assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_360, 0))));
|
||||
assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}-1790000120.fcproj", timestamp(1_790_000_120, 0))));
|
||||
assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}-1790000360.fcproj", timestamp(1_790_000_360, 0))));
|
||||
assert_eq!(list_auto_saves(&d, "Cut-Two").len(), 1);
|
||||
assert!(d.join("notes.txt").exists());
|
||||
rotate(&d, "Cut", 2).unwrap();
|
||||
|
||||
@@ -49,7 +49,7 @@ use filmcraft_project::{Project, ProjectView};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub use atomic::atomic_write;
|
||||
pub use atomic::{atomic_write, atomic_write_new};
|
||||
|
||||
/// The `format` tag written in every envelope.
|
||||
pub const FORMAT_ID: &str = "filmcraft.project";
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and
|
||||
//! random mutation of small libx264 streams must never panic or run away. Skips without ffmpeg.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use filmcraft_h264::Decoder;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// (name, extra x264 args, pix_fmt)
|
||||
const SEEDS: &[(&str, &str, &str)] = &[
|
||||
("h264_fz_cabac_b", "-profile:v high -bf 2 -g 8 -x264-params cabac=1:weightp=2:8x8dct=1", "yuv420p"),
|
||||
("h264_fz_cavlc", "-profile:v baseline -g 6 -x264-params cabac=0", "yuv420p"),
|
||||
("h264_fz_interlaced", "-profile:v high -flags +ildct+ilme -bf 1 -g 6 -x264-params tff=1:interlaced=1", "yuv420p"),
|
||||
("h264_fz_10bit_422", "-profile:v high422 -bf 1 -g 6", "yuv422p10le"),
|
||||
("h264_fz_multislice", "-profile:v main -g 6 -x264-params slices=3:cabac=1", "yuv420p"),
|
||||
];
|
||||
|
||||
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<u8>> {
|
||||
let out = filmcraft_testkit::fixtures_dir("h264").join(format!("{name}.h264"));
|
||||
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
let a: Vec<&str> = args.split_whitespace().collect();
|
||||
matches!(
|
||||
Command::new(ff)
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", "libx264"])
|
||||
.args(a)
|
||||
.args(["-f", "h264"])
|
||||
.arg(tmp)
|
||||
.stdin(Stdio::null())
|
||||
.status(),
|
||||
Ok(s) if s.success()
|
||||
)
|
||||
})?;
|
||||
std::fs::read(p).ok()
|
||||
}
|
||||
|
||||
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test.
|
||||
fn count_panics() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
eprintln!("PANIC: {info}");
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
fn run_all(d: &[u8], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
count_panics();
|
||||
let before = PANICS.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for threads in [1, 3] {
|
||||
let mut dec = Decoder::with_threads(threads);
|
||||
for au in common::split_access_units(d) {
|
||||
let _ = dec.decode(au, 0);
|
||||
}
|
||||
let _ = dec.decode(d, 1);
|
||||
let _ = dec.flush();
|
||||
let _ = dec.take_error();
|
||||
}
|
||||
}));
|
||||
assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}");
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("h264 fuzz") else { return };
|
||||
for &(name, args, pix) in SEEDS {
|
||||
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libx264") {
|
||||
filmcraft_testkit::skip("h264 fuzz", &format!("this ffmpeg has no libx264 encoder (seed {name})"));
|
||||
continue;
|
||||
}
|
||||
let Some(seed) = make(&ff, name, args, pix) else {
|
||||
panic!("ffmpeg has libx264 but could not encode the {name} seed");
|
||||
};
|
||||
let stride = (seed.len() / 300).max(1);
|
||||
for n in (0..seed.len()).step_by(stride) {
|
||||
run_all(&seed[..n], &format!("{name} truncate {n}"));
|
||||
}
|
||||
// SPS/PPS/first slice header region: every byte to a few values, every bit
|
||||
for p in 0..seed.len().min(120) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[p] = v;
|
||||
run_all(&d, &format!("{name} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[p] ^= 1 << b;
|
||||
run_all(&d, &format!("{name} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
|
||||
for i in 0..iters() {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(6) {
|
||||
let p = rng.below(d.len());
|
||||
if rng.below(2) == 0 {
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run_all(&d, &format!("{name} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
|
||||
fn iters() -> usize {
|
||||
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(400)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and
|
||||
//! random mutation of small libx265 streams must never panic or run away. Skips without ffmpeg.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use filmcraft_hevc::Decoder;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// (name, encoder, extra encoder args, pix_fmt)
|
||||
const SEEDS: &[(&str, &str, &str, &str)] = &[
|
||||
("hevc_fz_main_b", "libx265", "-x265-params log-level=error:bframes=2:keyint=8:min-keyint=8:sao=1:amp=1", "yuv420p"),
|
||||
("hevc_fz_main10", "libx265", "-x265-params log-level=error:bframes=1:keyint=6:min-keyint=6", "yuv420p10le"),
|
||||
("hevc_fz_wpp_slices", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6:wpp=1:slices=3", "yuv420p"),
|
||||
// x265 cannot encode tiles; kvazaar can, at 256 x 64 luma per tile at least (Main profile)
|
||||
("hevc_fz_tiles_kvazaar", "libkvazaar", "-s 512x128 -kvazaar-params tiles=2x2,gop=0,period=6,qp=40", "yuv420p"),
|
||||
("hevc_fz_422_10", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6", "yuv422p10le"),
|
||||
];
|
||||
|
||||
fn make(ff: &std::path::Path, name: &str, enc: &str, args: &str, pix: &str) -> Option<Vec<u8>> {
|
||||
let out = filmcraft_testkit::fixtures_dir("hevc").join(format!("{name}.hevc"));
|
||||
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
let a: Vec<&str> = args.split_whitespace().collect();
|
||||
matches!(
|
||||
Command::new(ff)
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", enc])
|
||||
.args(a)
|
||||
.args(["-f", "hevc"])
|
||||
.arg(tmp)
|
||||
.stdin(Stdio::null())
|
||||
.status(),
|
||||
Ok(s) if s.success()
|
||||
)
|
||||
})?;
|
||||
std::fs::read(p).ok()
|
||||
}
|
||||
|
||||
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test.
|
||||
fn count_panics() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
eprintln!("PANIC: {info}");
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
fn run_all(d: &[u8], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
count_panics();
|
||||
let before = PANICS.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for threads in [1, 3] {
|
||||
let mut dec = Decoder::with_threads(threads);
|
||||
for au in common::split_access_units(d) {
|
||||
let _ = dec.decode(au, 0);
|
||||
}
|
||||
let _ = dec.decode(d, 1);
|
||||
let _ = dec.flush();
|
||||
let _ = dec.take_error();
|
||||
}
|
||||
}));
|
||||
assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}");
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("hevc fuzz") else { return };
|
||||
for &(name, enc, args, pix) in SEEDS {
|
||||
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, enc) {
|
||||
filmcraft_testkit::skip("hevc fuzz", &format!("this ffmpeg has no {enc} encoder (seed {name})"));
|
||||
continue;
|
||||
}
|
||||
let Some(seed) = make(&ff, name, enc, args, pix) else {
|
||||
panic!("ffmpeg has {enc} but could not encode the {name} seed");
|
||||
};
|
||||
let stride = (seed.len() / 300).max(1);
|
||||
for n in (0..seed.len()).step_by(stride) {
|
||||
run_all(&seed[..n], &format!("{name} truncate {n}"));
|
||||
}
|
||||
// SPS/PPS/first slice header region: every byte to a few values, every bit
|
||||
for p in 0..seed.len().min(120) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[p] = v;
|
||||
run_all(&d, &format!("{name} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[p] ^= 1 << b;
|
||||
run_all(&d, &format!("{name} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
|
||||
for i in 0..iters() {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(6) {
|
||||
let p = rng.below(d.len());
|
||||
if rng.below(2) == 0 {
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run_all(&d, &format!("{name} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
|
||||
fn iters() -> usize {
|
||||
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(400)
|
||||
}
|
||||
@@ -13,8 +13,18 @@ initType following Table 9-4, default scaling lists (Table 7-6), intraPredAngle
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read().replace("−", "-").replace("–", "-")
|
||||
|
||||
def _spec_path(arg):
|
||||
"""Resolve a CLI path argument and require it to be an existing regular file."""
|
||||
path = Path(arg).resolve()
|
||||
if not path.is_file():
|
||||
sys.exit(f"not a readable file: {arg}")
|
||||
return path
|
||||
|
||||
|
||||
text = open(_spec_path(sys.argv[1]), encoding="utf-8").read().replace("−", "-").replace("–", "-")
|
||||
lines = text.split("\n")
|
||||
|
||||
|
||||
|
||||
@@ -65,7 +65,9 @@ pub fn file_url_to_path(url: &str) -> String {
|
||||
return url.to_string();
|
||||
}
|
||||
let rest = &url[7..];
|
||||
let rest = if rest.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest };
|
||||
let is_localhost =
|
||||
rest.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("localhost")) && rest.get(9..).is_some_and(|after| after.is_empty() || after.starts_with('/'));
|
||||
let rest = if is_localhost { &rest[9..] } else { rest };
|
||||
let decoded = percent_decode(rest);
|
||||
// file:///C:/x -> C:/x
|
||||
if decoded.len() >= 3 && decoded.starts_with('/') && is_windows_abs(&decoded[1..]) {
|
||||
@@ -692,6 +694,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_localhost_prefix_host_is_kept() {
|
||||
assert_eq!(file_url_to_path("file://localhost-nas/share/a.mov"), "//localhost-nas/share/a.mov");
|
||||
assert_eq!(file_url_to_path("file://LOCALHOSTX/a"), "//LOCALHOSTX/a");
|
||||
assert_eq!(file_url_to_path("file://localhost/a/b"), "/a/b");
|
||||
assert_eq!(file_url_to_path("file://LocalHost/a"), "/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths() {
|
||||
assert_eq!(resolve_path("media/a.mov", Some("/proj")), "/proj/media/a.mov");
|
||||
|
||||
@@ -673,8 +673,8 @@ pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report:
|
||||
out.push_str(r);
|
||||
}
|
||||
out.push_str(" </resources>\n");
|
||||
out.push_str(&format!(" <library>\n <event name=\"{}\">\n", crate::xml::escape(&p.name)));
|
||||
out.push_str(&format!(" <project name=\"{}\">\n", crate::xml::escape(&name)));
|
||||
out.push_str(&format!(" <library>\n <event name=\"{}\">\n", crate::xml::escape_attr(&p.name)));
|
||||
out.push_str(&format!(" <project name=\"{}\">\n", crate::xml::escape_attr(&name)));
|
||||
out.push_str(&body);
|
||||
out.push_str(" </project>\n </event>\n </library>\n</fcpxml>\n");
|
||||
Ok(out)
|
||||
@@ -728,7 +728,7 @@ impl Exp<'_, '_> {
|
||||
let start = m.info.start_timecode.map(|f| mrate.tick_of(f)).unwrap_or(Tick::ZERO);
|
||||
let mut a = format!(
|
||||
" <asset id=\"{id}\" name=\"{}\" start=\"{}\" duration=\"{}\" hasVideo=\"{}\" hasAudio=\"{}\"",
|
||||
crate::xml::escape(&name),
|
||||
crate::xml::escape_attr(&name),
|
||||
fmt_time(start, mrate),
|
||||
fmt_time(m.info.duration, mrate),
|
||||
m.info.video.is_some() as u8,
|
||||
@@ -742,7 +742,7 @@ impl Exp<'_, '_> {
|
||||
}
|
||||
let path = item_path(self.p, item).unwrap_or("");
|
||||
let src = if crate::common::is_absolute(path) { path_to_file_url(path, false) } else { path.to_string() };
|
||||
a.push_str(&format!(">\n <media-rep kind=\"original-media\" src=\"{}\"/>\n </asset>\n", crate::xml::escape(&src)));
|
||||
a.push_str(&format!(">\n <media-rep kind=\"original-media\" src=\"{}\"/>\n </asset>\n", crate::xml::escape_attr(&src)));
|
||||
self.res_out.push(a);
|
||||
self.assets.insert(item, Res { id: id.clone() });
|
||||
let _ = file_name;
|
||||
@@ -764,7 +764,7 @@ impl Exp<'_, '_> {
|
||||
let body = self.sequence_xml(item, &fmt, 3);
|
||||
self.building.pop();
|
||||
let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default();
|
||||
self.res_out.push(format!(" <media id=\"{id}\" name=\"{}\">\n{body} </media>\n", crate::xml::escape(&name)));
|
||||
self.res_out.push(format!(" <media id=\"{id}\" name=\"{}\">\n{body} </media>\n", crate::xml::escape_attr(&name)));
|
||||
id
|
||||
}
|
||||
|
||||
@@ -773,7 +773,11 @@ impl Exp<'_, '_> {
|
||||
return r.id.clone();
|
||||
}
|
||||
let id = self.rid();
|
||||
self.res_out.push(format!(" <effect id=\"{id}\" name=\"{}\" uid=\"filmcraft.{}\"/>\n", crate::xml::escape(name), crate::xml::escape(id_hint)));
|
||||
self.res_out.push(format!(
|
||||
" <effect id=\"{id}\" name=\"{}\" uid=\"filmcraft.{}\"/>\n",
|
||||
crate::xml::escape_attr(name),
|
||||
crate::xml::escape_attr(id_hint)
|
||||
));
|
||||
self.effects.insert(name.to_string(), Res { id: id.clone() });
|
||||
id
|
||||
}
|
||||
@@ -934,7 +938,7 @@ impl Exp<'_, '_> {
|
||||
"<transition name=\"{n}\" offset=\"{}\" duration=\"{}\"><filter-video ref=\"{r}\" name=\"{n}\"/></transition>",
|
||||
fmt_time(tc0 + tr.start, rate),
|
||||
fmt_time(tr.duration, rate),
|
||||
n = crate::xml::escape(&name)
|
||||
n = crate::xml::escape_attr(&name)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -946,7 +950,7 @@ impl Exp<'_, '_> {
|
||||
let is_seq = matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_)));
|
||||
let generator = generator_of(self.p, base).cloned();
|
||||
let lane_attr = lane.map(|l| format!(" lane=\"{l}\"")).unwrap_or_default();
|
||||
let name = crate::xml::escape(&it.name);
|
||||
let name = crate::xml::escape_attr(&it.name);
|
||||
if let Some(g) = generator {
|
||||
self.report.warn(format!("generator \"{}\" is not supported in FCPXML; written as a gap", g.label()));
|
||||
return El {
|
||||
@@ -1043,7 +1047,7 @@ impl Exp<'_, '_> {
|
||||
let n = e.def().map(|d| d.name).unwrap_or(&e.effect).to_string();
|
||||
let r = self.effect(&n, &e.effect);
|
||||
let tag = if kind == TrackKind::Video { "filter-video" } else { "filter-audio" };
|
||||
let mut s = format!("<{tag} ref=\"{r}\" name=\"{}\"", crate::xml::escape(&n));
|
||||
let mut s = format!("<{tag} ref=\"{r}\" name=\"{}\"", crate::xml::escape_attr(&n));
|
||||
if !e.enabled {
|
||||
s.push_str(" enabled=\"0\"");
|
||||
}
|
||||
@@ -1169,8 +1173,8 @@ fn kparam_xml(
|
||||
fn marker_xml(m: &Marker, local: Tick, rate: FrameRate) -> String {
|
||||
let tag = if m.kind == MarkerKind::Chapter { "chapter-marker" } else { "marker" };
|
||||
let dur = if m.duration > Tick::ZERO { m.duration } else { rate.frame_duration() };
|
||||
let note = if m.comment.is_empty() { String::new() } else { format!(" note=\"{}\"", crate::xml::escape(&m.comment)) };
|
||||
format!("<{tag} start=\"{}\" duration=\"{}\" value=\"{}\"{note}/>", fmt_time(local, rate), fmt_time(dur, rate), crate::xml::escape(&m.name))
|
||||
let note = if m.comment.is_empty() { String::new() } else { format!(" note=\"{}\"", crate::xml::escape_attr(&m.comment)) };
|
||||
format!("<{tag} start=\"{}\" duration=\"{}\" value=\"{}\"{note}/>", fmt_time(local, rate), fmt_time(dur, rate), crate::xml::escape_attr(&m.name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -20,6 +20,21 @@ pub(crate) fn escape(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// Like `escape`, but also encodes whitespace that attribute-value
|
||||
/// normalization would otherwise turn into spaces.
|
||||
pub(crate) fn escape_attr(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\n' => o.push_str(" "),
|
||||
'\r' => o.push_str(" "),
|
||||
'\t' => o.push_str("	"),
|
||||
c => o.push_str(&escape(c.encode_utf8(&mut [0; 4]))),
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Pretty-printing XML writer (two-space indent, one element per line).
|
||||
pub(crate) struct XmlWriter {
|
||||
out: String,
|
||||
@@ -46,7 +61,7 @@ impl XmlWriter {
|
||||
self.out.push('<');
|
||||
self.out.push_str(name);
|
||||
for (k, v) in attrs {
|
||||
let _ = write!(self.out, " {k}=\"{}\"", escape(v));
|
||||
let _ = write!(self.out, " {k}=\"{}\"", escape_attr(v));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,4 +184,16 @@ mod tests {
|
||||
assert_eq!(num(-0.0), "0");
|
||||
assert_eq!(num(0.1 + 0.2).parse::<f64>().unwrap(), 0.1 + 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_whitespace_roundtrip() {
|
||||
let mut w = XmlWriter::new(None);
|
||||
w.open("a", &[("name", "x\ny\tz\r")]);
|
||||
w.text("b", "a\nb");
|
||||
let s = w.finish();
|
||||
let d = parse(&s).unwrap();
|
||||
let a = d.root_element();
|
||||
assert_eq!(a.attribute("name"), Some("x\ny\tz\r"));
|
||||
assert_eq!(child(a, "b").and_then(|b| b.text()), Some("a\nb"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ parameters, so a random-access point without its own sequence header still decod
|
||||
|
||||
- Main Profile at Low/Main/High-1440/High Level, 4:2:2 Profile (IMX / D-10, XDCAM HD422),
|
||||
Simple Profile, MPEG-1 (including D pictures). 4:4:4 is refused (no profile in use for it).
|
||||
- Pictures above 2^25 luma samples (8192 x 4096; 8K UHD at 7680 x 4320 fits, and the largest MPEG-2 level,
|
||||
High, is 1920 x 1152) are refused as `Unsupported`: the 14-bit header fields allow 16383 x 16383, which
|
||||
would ask for gigabytes per frame from hostile input.
|
||||
- Frame pictures with frame and field motion compensation, dual prime, frame and field DCT;
|
||||
field pictures with field, 16x8 and dual-prime motion compensation, the second field of a
|
||||
P frame predicted from the first; open and closed GOPs; B pictures without a forward
|
||||
|
||||
@@ -163,6 +163,9 @@ struct Seq {
|
||||
/// MPEG-1 pel_aspect_ratio (height/width of a sample) ×10000, Table 2-D.2.
|
||||
const MPEG1_PEL_ASPECT: [u32; 15] = [0, 10000, 6735, 7031, 7615, 8055, 8437, 8935, 9157, 9815, 10255, 10695, 10950, 11575, 12015];
|
||||
|
||||
/// Largest accepted luma area (8192 x 4096; 8K UHD is 7680 x 4320).
|
||||
const MAX_PICTURE_AREA: u64 = 1 << 25;
|
||||
|
||||
impl Seq {
|
||||
fn new(hdr: SequenceHeader, ext: Option<SequenceExtension>, display: Option<DisplayExtension>) -> Result<Seq> {
|
||||
let (w, h) = match &ext {
|
||||
@@ -175,7 +178,9 @@ impl Seq {
|
||||
3 => ChromaFormat::Yuv444,
|
||||
_ => return Err(Error::Invalid("reserved chroma_format".into())),
|
||||
};
|
||||
if w > 16383 || h > 16383 {
|
||||
// The 14-bit sizes allow 16383 x 16383 (gigabytes per frame, several frames and a gray reference
|
||||
// held); no real stream exceeds 8K, so cap the area there.
|
||||
if w > 16383 || h > 16383 || u64::from(w) * u64::from(h) > MAX_PICTURE_AREA {
|
||||
return Err(Error::Unsupported(format!("{w}x{h}")));
|
||||
}
|
||||
let progressive_sequence = ext.is_none_or(|e| e.progressive_sequence);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Hostile-input robustness: truncation at every length, byte/bit mutation and header-field
|
||||
//! sweeps over a small ffmpeg-generated stream must never panic or run away (errors are fine).
|
||||
//! Skips when ffmpeg is missing.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use filmcraft_mpeg2v::{Decoder, access_units, probe, scan_access_unit};
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
const SPECS: [common::Spec; 3] = [
|
||||
common::Spec {
|
||||
name: "m2v_fuzz_seed",
|
||||
lavfi: "testsrc2=size=64x48:rate=25",
|
||||
frames: 8,
|
||||
enc: &["-c:v", "mpeg2video", "-bf", "2", "-g", "6", "-b:v", "500k"],
|
||||
pix_fmt: "yuv420p",
|
||||
},
|
||||
common::Spec {
|
||||
name: "m2v_fuzz_seed_interlaced",
|
||||
lavfi: "testsrc2=size=96x64:rate=25,tinterlace=mode=interleave_top,setfield=tff",
|
||||
frames: 6,
|
||||
enc: &["-c:v", "mpeg2video", "-flags", "+ilme+ildct", "-bf", "1", "-g", "6", "-b:v", "800k"],
|
||||
pix_fmt: "yuv420p",
|
||||
},
|
||||
common::Spec {
|
||||
name: "m2v_fuzz_seed_422",
|
||||
lavfi: "testsrc2=size=64x48:rate=25",
|
||||
frames: 4,
|
||||
enc: &["-c:v", "mpeg2video", "-pix_fmt", "yuv422p", "-profile:v", "0", "-b:v", "1M"],
|
||||
pix_fmt: "yuv422p",
|
||||
},
|
||||
];
|
||||
|
||||
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test.
|
||||
fn count_panics() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
eprintln!("PANIC: {info}");
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
fn run_all(d: &[u8], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
count_panics();
|
||||
let before = PANICS.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
let _ = probe(d);
|
||||
let _ = scan_access_unit(d);
|
||||
for threads in [false, true] {
|
||||
let mut dec = Decoder::new();
|
||||
dec.set_threads(threads);
|
||||
for (i, r) in access_units(d).into_iter().enumerate() {
|
||||
let _ = dec.decode(&d[r], i as i64);
|
||||
}
|
||||
// also the whole buffer as one unit
|
||||
let _ = dec.decode(d, 99);
|
||||
let _ = dec.flush();
|
||||
}
|
||||
}));
|
||||
assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}");
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpeg2v fuzz") else { return };
|
||||
for spec in &SPECS {
|
||||
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "mpeg2video") {
|
||||
filmcraft_testkit::skip("mpeg2v fuzz", &format!("this ffmpeg has no mpeg2video encoder (seed {})", spec.name));
|
||||
continue;
|
||||
}
|
||||
let Some(path) = common::make(&ff, spec) else {
|
||||
panic!("ffmpeg has mpeg2video but could not encode the {} seed", spec.name);
|
||||
};
|
||||
fuzz(spec.name, &std::fs::read(path).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
fn fuzz(name: &str, seed: &[u8]) {
|
||||
// every truncation length (stride keeps it quick on larger seeds)
|
||||
let stride = (seed.len() / 600).max(1);
|
||||
for n in (0..seed.len()).step_by(stride) {
|
||||
run_all(&seed[..n], &format!("{name} truncate {n}"));
|
||||
}
|
||||
// header region: every byte to 0 / 0xff / 0x80 / each single bit
|
||||
for p in 0..seed.len().min(96) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01] {
|
||||
let mut d = seed.to_vec();
|
||||
d[p] = v;
|
||||
run_all(&d, &format!("{name} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.to_vec();
|
||||
d[p] ^= 1 << b;
|
||||
run_all(&d, &format!("{name} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
// random multi-mutations
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15);
|
||||
for i in 0..iters() {
|
||||
let mut d = seed.to_vec();
|
||||
for _ in 0..1 + rng.below(6) {
|
||||
let p = rng.below(d.len());
|
||||
if rng.below(2) == 0 {
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run_all(&d, &format!("{name} random {i}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
|
||||
fn iters() -> usize {
|
||||
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(2500)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! A 14-byte sequence header must not make the decoder size its frame buffers at 16383 x 16383
|
||||
//! (gigabytes per picture). Hand-built, no ffmpeg.
|
||||
|
||||
use filmcraft_mpeg2v::Decoder;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Bits {
|
||||
bytes: Vec<u8>,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl Bits {
|
||||
fn put(&mut self, value: u32, bits: usize) {
|
||||
for i in (0..bits).rev() {
|
||||
if self.n.is_multiple_of(8) {
|
||||
self.bytes.push(0);
|
||||
}
|
||||
if (value >> i) & 1 == 1
|
||||
&& let Some(b) = self.bytes.last_mut()
|
||||
{
|
||||
*b |= 0x80 >> (self.n % 8);
|
||||
}
|
||||
self.n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequence header plus sequence extension for a `w` x `h` 4:4:4 stream.
|
||||
fn sequence(w: u32, h: u32, chroma_format: u32) -> Vec<u8> {
|
||||
let mut s = Bits::default();
|
||||
s.put(w & 0xfff, 12);
|
||||
s.put(h & 0xfff, 12);
|
||||
s.put(1, 4); // aspect_ratio_information
|
||||
s.put(4, 4); // frame_rate_code
|
||||
s.put(1000, 18); // bit_rate_value
|
||||
s.put(1, 1); // marker
|
||||
s.put(100, 10); // vbv_buffer_size_value
|
||||
s.put(0, 3); // constrained_parameters_flag, load_intra, load_non_intra
|
||||
let mut e = Bits::default();
|
||||
e.put(1, 4); // sequence_extension
|
||||
e.put(0x48, 8); // profile_and_level_indication
|
||||
e.put(1, 1); // progressive_sequence
|
||||
e.put(chroma_format, 2);
|
||||
e.put(w >> 12, 2);
|
||||
e.put(h >> 12, 2);
|
||||
e.put(0, 12); // bit_rate_extension
|
||||
e.put(1, 1); // marker
|
||||
e.put(0, 8 + 1 + 2 + 5); // vbv_buffer_size_extension, low_delay, frame_rate_extension_n/d
|
||||
let mut out = vec![0, 0, 1, 0xb3];
|
||||
out.extend(s.bytes);
|
||||
out.extend([0, 0, 1, 0xb5]);
|
||||
out.extend(e.bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn accepted(w: u32, h: u32, chroma_format: u32) -> bool {
|
||||
let mut dec = Decoder::new();
|
||||
let _ = dec.decode(&sequence(w, h, chroma_format), 0);
|
||||
dec.sequence().is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_sequences_are_accepted() {
|
||||
assert!(accepted(1920, 1080, 1));
|
||||
assert!(accepted(7680, 4320, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gigantic_sequences_are_refused() {
|
||||
assert!(!accepted(16383, 16383, 3));
|
||||
assert!(!accepted(16383, 16383, 1));
|
||||
assert!(!accepted(8192, 8192, 1));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
|
||||
//! truncation at (nearly) every length must never panic or run away (errors are fine).
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use filmcraft_prores::*;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn seeds() -> Vec<Vec<u8>> {
|
||||
let mut v = Vec::new();
|
||||
for (p, w, h) in [(Profile::Proxy, 96u32, 48u32), (Profile::Hq, 70, 38), (Profile::P4444, 64, 32), (Profile::P4444Xq, 48, 20)] {
|
||||
let chroma = p.chroma();
|
||||
let mut f = Frame::new(w, h, chroma, 10, chroma == ChromaFormat::Yuv444);
|
||||
for (i, s) in f.y.iter_mut().enumerate() {
|
||||
*s = (64 + (i * 37) % 800) as u16;
|
||||
}
|
||||
if let Some(a) = f.alpha.as_mut() {
|
||||
for (i, s) in a.iter_mut().enumerate() {
|
||||
*s = if i % 5 == 0 { 1023 } else { (i % 1024) as u16 };
|
||||
}
|
||||
}
|
||||
let mut cfg = EncoderConfig::new(p, w, h);
|
||||
cfg.log2_slice_mbs = (w % 4) as u8;
|
||||
if let Ok(d) = Encoder::with_config(cfg).encode(&f) {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn run(d: &[u8], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for depth in [None, Some(8), Some(16)] {
|
||||
let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() });
|
||||
}
|
||||
let _ = probe(d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic: {label}");
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_frames_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let seeds = seeds();
|
||||
assert!(!seeds.is_empty());
|
||||
for (si, seed) in seeds.iter().enumerate() {
|
||||
let stride = (seed.len() / 400).max(1);
|
||||
for n in (0..seed.len()).step_by(stride) {
|
||||
run(&seed[..n], &format!("seed {si} truncate {n}"));
|
||||
}
|
||||
for p in 0..seed.len().min(256) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[p] = v;
|
||||
run(&d, &format!("seed {si} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[p] ^= 1 << b;
|
||||
run(&d, &format!("seed {si} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
// Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions).
|
||||
for p in 0..seed.len().min(128) {
|
||||
for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] {
|
||||
let mut d = seed.clone();
|
||||
if let Some(s) = d.get_mut(p..p + 4) {
|
||||
s.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
run(&d, &format!("seed {si} u32 {p}={v:#x}"));
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
|
||||
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000);
|
||||
for i in 0..iters {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let p = rng.below(d.len());
|
||||
if rng.below(2) == 0 {
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run(&d, &format!("seed {si} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Hang guard for hostile-input sweeps: a test body runs on a worker thread, and a body that does not finish
|
||||
//! within the budget fails the test (naming the input it was last working on) instead of stalling CI forever.
|
||||
//! A merely slow body is not a failure, so a loaded runner or slow VM cannot fail a correct decoder.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
static PROGRESS: Mutex<String> = Mutex::new(String::new());
|
||||
|
||||
/// Record the input being worked on, so a hang report can name it. Cheap enough to call per input.
|
||||
pub fn set_progress(label: &str) {
|
||||
let mut p = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
p.clear();
|
||||
p.push_str(label);
|
||||
}
|
||||
|
||||
/// Run `f` on a worker thread and wait at most `secs` seconds (`FILMCRAFT_HOSTILE_TIMEOUT_SECS` overrides the
|
||||
/// budget). A panic in `f` is re-raised here; a timeout panics with the last [`set_progress`] label. A hung
|
||||
/// worker is abandoned (the failing test process exits anyway).
|
||||
pub fn with_deadline(secs: u64, f: impl FnOnce() + Send + 'static) {
|
||||
let secs = std::env::var("FILMCRAFT_HOSTILE_TIMEOUT_SECS").ok().and_then(|v| v.parse::<u64>().ok()).filter(|&n| n > 0).unwrap_or(secs);
|
||||
run_with_deadline(secs, f);
|
||||
}
|
||||
|
||||
fn run_with_deadline(secs: u64, f: impl FnOnce() + Send + 'static) {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
// a failed spawn shows up as "worker gone" below
|
||||
let _ = std::thread::Builder::new().name("hostile deadline".into()).spawn(move || {
|
||||
let _ = tx.send(catch_unwind(AssertUnwindSafe(f)));
|
||||
});
|
||||
match rx.recv_timeout(Duration::from_secs(secs)) {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(payload)) => resume_unwind(payload),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
let last = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner).clone();
|
||||
panic!("hostile-input test did not finish in {secs}s (hang); last input: {last}");
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => panic!("hostile-input worker thread could not run"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_slow_body_passes() {
|
||||
with_deadline(30, || std::thread::sleep(Duration::from_millis(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "boom")]
|
||||
fn a_panic_in_the_body_is_re_raised() {
|
||||
with_deadline(30, || panic!("boom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "last input: seed 7 byte 3")]
|
||||
fn a_hang_fails_and_names_the_input() {
|
||||
// a body that outlives the 1 s budget; the inner fn, so FILMCRAFT_HOSTILE_TIMEOUT_SECS cannot lengthen it
|
||||
set_progress("seed 7 byte 3");
|
||||
run_with_deadline(1, || std::thread::sleep(Duration::from_secs(30)));
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,17 @@
|
||||
//! ffmpeg/ffprobe are external test oracles and fixture generators only; they are never linked
|
||||
//! or shipped (AGENTS.md §2).
|
||||
|
||||
pub mod deadline;
|
||||
pub mod fixtures;
|
||||
pub mod golden;
|
||||
pub mod oracle;
|
||||
pub mod wav;
|
||||
|
||||
pub use deadline::{set_progress, with_deadline};
|
||||
pub use fixtures::{fixtures_dir, temp_path, workspace_root};
|
||||
pub use oracle::{
|
||||
ffmpeg, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip,
|
||||
ffmpeg, ffmpeg_has_encoder, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required,
|
||||
skip,
|
||||
};
|
||||
|
||||
/// `let ff = require_ffmpeg!();` — the ffmpeg path, or return from the test after printing
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! `FILMCRAFT_REQUIRE_ORACLES=1` is set (CI), which turns every skip into a failure.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Environment variable that makes missing oracles a hard failure.
|
||||
@@ -116,6 +117,19 @@ pub fn ffmpeg_or_skip(context: &str) -> Option<PathBuf> {
|
||||
tool_or_skip("ffmpeg", ffmpeg(), context)
|
||||
}
|
||||
|
||||
/// Whether `ffmpeg -encoders` lists `encoder` (e.g. `libx265`): lets a test tell "this ffmpeg lacks the encoder"
|
||||
/// (skip) from "the encoder is there but failed" (a real failure).
|
||||
pub fn ffmpeg_has_encoder(ffmpeg: &Path, encoder: &str) -> bool {
|
||||
let Ok(out) = Command::new(ffmpeg).args(["-hide_banner", "-encoders"]).stdin(Stdio::null()).output() else { return false };
|
||||
encoders_output_lists(&String::from_utf8_lossy(&out.stdout), encoder)
|
||||
}
|
||||
|
||||
/// Pure function of the `ffmpeg -encoders` output: whether `encoder` is one of the listed names.
|
||||
fn encoders_output_lists(output: &str, encoder: &str) -> bool {
|
||||
// rows look like ` V....D libx264 libx264 H.264 …`; the name is the second whitespace-separated token
|
||||
output.lines().any(|line| line.split_whitespace().nth(1) == Some(encoder))
|
||||
}
|
||||
|
||||
/// ffprobe, or `None` after reporting a skip (see [`skip`]).
|
||||
pub fn ffprobe_or_skip(context: &str) -> Option<PathBuf> {
|
||||
tool_or_skip("ffprobe", ffprobe(), context)
|
||||
@@ -177,6 +191,15 @@ pub fn ffmpeg_with_format_or_skip(context: &str, format_name: &str, min_version:
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encoder_lookup_matches_the_name_column_only() {
|
||||
let out = " V....D libx264 libx264 H.264 / AVC\n V....D libx265 libx265 H.265 / HEVC (libx265)\n A....D aac AAC (libx264 inside)\n";
|
||||
assert!(encoders_output_lists(out, "libx264"));
|
||||
assert!(encoders_output_lists(out, "libx265"));
|
||||
assert!(!encoders_output_lists(out, "libsvtav1"));
|
||||
assert!(!encoders_output_lists(out, "H.264"));
|
||||
}
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
|
||||
+41
-2
@@ -121,6 +121,7 @@ impl SubAssign for Tick {
|
||||
}
|
||||
|
||||
/// A half-open time range `[start, start + duration)`.
|
||||
/// Zero or negative durations are empty and never overlap another range.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct TimeRange {
|
||||
pub start: Tick,
|
||||
@@ -141,7 +142,7 @@ impl TimeRange {
|
||||
t >= self.start && t < self.end()
|
||||
}
|
||||
pub fn overlaps(&self, o: &TimeRange) -> bool {
|
||||
self.start < o.end() && o.start < self.end()
|
||||
!self.is_empty() && !o.is_empty() && self.start < o.end() && o.start < self.end()
|
||||
}
|
||||
pub fn intersect(&self, o: &TimeRange) -> Option<TimeRange> {
|
||||
let s = self.start.max(o.start);
|
||||
@@ -480,7 +481,15 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i
|
||||
} else {
|
||||
parts
|
||||
.iter()
|
||||
.map(|p| if p.is_empty() { Ok(0) } else { p.parse::<i64>().map_err(|_| ParseError(format!("bad field `{p}`"))) })
|
||||
.map(|p| {
|
||||
if p.is_empty() {
|
||||
Ok(0)
|
||||
} else if !p.bytes().all(|b| b.is_ascii_digit()) {
|
||||
Err(ParseError(format!("bad field `{p}`")))
|
||||
} else {
|
||||
p.parse::<i64>().map_err(|_| ParseError(format!("bad field `{p}`")))
|
||||
}
|
||||
})
|
||||
.collect::<Result<_, _>>()?
|
||||
};
|
||||
if nums.len() > 4 {
|
||||
@@ -514,6 +523,28 @@ mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn empty_time_ranges_never_overlap() {
|
||||
let clip = TimeRange::new(Tick(0), Tick(10));
|
||||
for duration in [0, -1, -5] {
|
||||
let empty = TimeRange::new(Tick(5), Tick(duration));
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.intersect(&clip), None);
|
||||
assert!(!empty.overlaps(&clip), "duration {duration}");
|
||||
assert!(!clip.overlaps(&empty), "duration {duration}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_range_overlap_respects_half_open_boundaries() {
|
||||
let clip = TimeRange::new(Tick(0), Tick(10));
|
||||
for (start, duration, expected) in [(-5, 5, false), (10, 5, false), (-5, 6, true), (9, 5, true), (2, 3, true)] {
|
||||
let other = TimeRange::new(Tick(start), Tick(duration));
|
||||
assert_eq!(clip.overlaps(&other), expected);
|
||||
assert_eq!(other.overlaps(&clip), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_display_has_no_negative_zero() {
|
||||
let f = |t: i64| format_time(Tick(t), FrameRate::default(), false, TimeDisplay::Seconds, 48000);
|
||||
@@ -720,6 +751,14 @@ mod tests {
|
||||
assert!(parse_timecode("abc", r, false, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_field_rejected() {
|
||||
let r = FrameRate::FPS_25;
|
||||
assert!(parse_timecode("00:00:-5:00", r, false, 0).is_err());
|
||||
assert!(parse_timecode("00:+1:00:00", r, false, 0).is_err());
|
||||
assert_eq!(parse_timecode("00:00:10:00", r, false, 0).unwrap(), 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_modes() {
|
||||
let r = FrameRate::FPS_24;
|
||||
|
||||
@@ -95,6 +95,24 @@ pub fn normalize(text: &str, lang: Lang) -> Result<Vec<Token>, NormalizeError> {
|
||||
out.push(Token::Punct(c));
|
||||
}
|
||||
}
|
||||
'-' if matches!(peek(rest, 1), Some('$' | '€' | '£'))
|
||||
&& !text
|
||||
.get(..pos)
|
||||
.and_then(|before| before.chars().next_back())
|
||||
.is_some_and(|b| b.is_alphanumeric() || matches!(b, '$' | '€' | '£' | '-')) =>
|
||||
{
|
||||
// A negative amount ("-$5"): keep the sign so a loss is not read as a gain.
|
||||
// A hyphen right after a word or amount ("$10-$20") is a range, not a sign.
|
||||
pos += 1;
|
||||
match parse_money(rest.get(1..).unwrap_or(""), lang) {
|
||||
Some((words, used)) => {
|
||||
pos += used;
|
||||
out.push(Token::Word("minus".into()));
|
||||
push_words(&mut out, &words);
|
||||
}
|
||||
None => out.push(Token::Punct(c)),
|
||||
}
|
||||
}
|
||||
_ if c.is_ascii_digit() => match parse_number_cluster(rest, lang) {
|
||||
Some((tokens, used)) => {
|
||||
pos += used;
|
||||
@@ -413,6 +431,8 @@ fn parse_time(rest: &str) -> Option<(Vec<Token>, usize)> {
|
||||
for (lit, letter_pair) in [("am", ['a', 'm']), ("pm", ['p', 'm'])] {
|
||||
if after.get(..2).is_some_and(|s| s.eq_ignore_ascii_case(lit)) {
|
||||
for letter in letter_pair {
|
||||
// Uppercase "A" so it is read as a letter name, not the article.
|
||||
let letter = if letter == 'a' { 'A' } else { letter };
|
||||
tokens.push(Token::Word(letter.into()));
|
||||
}
|
||||
used += 2;
|
||||
@@ -944,7 +964,7 @@ fn parse_wordish(rest: &str, _lang: Lang) -> Option<(Vec<Token>, usize)> {
|
||||
if word.len() == 1 && matches!(lower.as_str(), "a" | "p") && peek(rest, used) == Some('.') && matches!(peek(rest, used + 1), Some('m') | Some('M')) {
|
||||
let after_m = used + 2;
|
||||
if peek(rest, after_m).is_none_or(|c| !c.is_alphanumeric()) {
|
||||
let mut tokens = vec![Token::Word(word.to_ascii_lowercase())];
|
||||
let mut tokens = vec![Token::Word(if lower == "a" { "A".into() } else { lower.clone() })];
|
||||
tokens.push(Token::Word("m".into()));
|
||||
let consumed = if peek(rest, after_m) == Some('.') { after_m + 1 } else { after_m };
|
||||
return Some((tokens, consumed));
|
||||
@@ -1237,6 +1257,24 @@ mod tests {
|
||||
assert_eq!(words("$0.50", Lang::EnUs), "zero dollars and fifty cents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_money_keeps_its_sign() {
|
||||
for lang in [Lang::EnUs, Lang::EnGb] {
|
||||
assert_eq!(words("-$5", lang), "minus five dollars");
|
||||
assert_eq!(words("-€3.50", lang), "minus three euros and fifty cents");
|
||||
assert_eq!(words("-£2", lang), "minus two pounds");
|
||||
assert_eq!(words("a refund of -$1.05", lang), "a refund of minus one dollar and five cents");
|
||||
assert_eq!(words("(-$5)", lang), "( minus five dollars )");
|
||||
// A hyphen between two amounts is a range, not a sign.
|
||||
assert_eq!(words("$10-$20", lang), "ten dollars - twenty dollars");
|
||||
assert_eq!(words("€5-€8", lang), "five euros - eight euros");
|
||||
assert_eq!(words("$10.50-$20", lang), "ten dollars and fifty cents - twenty dollars");
|
||||
// A hyphen after a word or before a non-amount stays punctuation.
|
||||
assert_eq!(words("a-$5", lang), "a - five dollars");
|
||||
assert!(!words("-$x", lang).contains("minus"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_fraction_beyond_two_digits_is_not_hundreds_of_cents() {
|
||||
for lang in [Lang::EnUs, Lang::EnGb] {
|
||||
@@ -1327,7 +1365,7 @@ mod tests {
|
||||
assert_eq!(words("23:59", Lang::EnUs), "twenty-three fifty-nine");
|
||||
assert_eq!(words("3:30pm", Lang::EnUs), "three thirty p m");
|
||||
assert_eq!(words("3:30 p.m.", Lang::EnUs), "three thirty p m");
|
||||
assert_eq!(words("9:00 a.m.", Lang::EnUs), "nine zero a m");
|
||||
assert_eq!(words("9:00 a.m.", Lang::EnUs), "nine zero A m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1335,6 +1335,16 @@ PERCENT P ER0 S EH1 N T
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn am_reads_the_letter_name_not_the_article() {
|
||||
for text in ["At 9:00 a.m.", "At 9:00am"] {
|
||||
let chunks = phonemize(text, Lang::EnUs, &test_lex(), &Overrides::default()).unwrap();
|
||||
let joined = format!("{chunks:?}");
|
||||
assert!(joined.contains("ˈA"), "{text}: {joined}");
|
||||
assert!(!joined.contains('ə'), "{text}: {joined}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_output_char_is_in_symbols() {
|
||||
let inputs = [
|
||||
|
||||
@@ -1382,9 +1382,11 @@ mod tests {
|
||||
output.textures_delta.clear();
|
||||
ctx.fonts_mut(|fonts| {
|
||||
// every theme family falls back to the craft-fonts (when built with them; no system
|
||||
// font is added then) or to the system Japanese font
|
||||
// font is added then) or to the system Japanese font. Installed right-to-left faces
|
||||
// (`rtl::install`) come after every other fallback by design, so they are left out.
|
||||
for family in crate::theme::font_families() {
|
||||
let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default();
|
||||
let mut stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default();
|
||||
stack.retain(|n| !crate::rtl::is_system_face(n));
|
||||
if craft {
|
||||
assert!(stack.last().is_some_and(|n| n.starts_with("craft:")), "{family:?}: {stack:?}");
|
||||
assert!(!stack.iter().any(|n| n == JAPANESE_FONT), "{family:?}: {stack:?}");
|
||||
|
||||
@@ -232,6 +232,10 @@ pub struct Playback {
|
||||
/// The audio clock's last reading and when (egui time, s) it last moved: a device that stops
|
||||
/// consuming samples hands the clock back to the wall clock (see [`AUDIO_STALL_S`]).
|
||||
audio_seen: (u64, f64),
|
||||
/// When playback lost its output (a failed open or a dead stream), when it is opened again
|
||||
/// (egui time, s; 0 = not retrying). Pause/play proves a fresh stream works, so a stream the
|
||||
/// device keeps dropping is reopened on a timer (see [`AUDIO_RETRY_S`]).
|
||||
audio_retry_at: f64,
|
||||
/// Audio underruns for the current (or last) play (desktop: sound is mixed ahead).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub audio_stats: std::sync::Arc<play_ahead::AudioStats>,
|
||||
@@ -256,6 +260,11 @@ pub struct Playback {
|
||||
/// under this.
|
||||
pub const AUDIO_STALL_S: f64 = 1.0;
|
||||
|
||||
/// How often (s) playback re-opens the output after its stream or device failed, while playing
|
||||
/// goes on silently on the wall clock. Reopening is what pause/play does by hand; doing it on a
|
||||
/// timer brings the sound back when the device recovers (#2503).
|
||||
pub const AUDIO_RETRY_S: f64 = 2.0;
|
||||
|
||||
/// How long `ui.screenshot` waits for the window to present the frame.
|
||||
const SCREENSHOT_TIMEOUT_S: f64 = 10.0;
|
||||
/// How long `ui.screenshot` waits for the UI to settle (monitors showing their exact frames, the
|
||||
@@ -770,7 +779,7 @@ impl FilmcraftApp {
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.playback.anchor_time = now;
|
||||
self.playback.audio_seen = (0, now);
|
||||
self.start_audio();
|
||||
self.start_audio(now);
|
||||
}
|
||||
if let Some(note) = self.audio.as_ref().and_then(|a| a.note()) {
|
||||
self.ui.status = note;
|
||||
@@ -1000,6 +1009,7 @@ impl FilmcraftApp {
|
||||
a.stop();
|
||||
}
|
||||
self.playback.audio_clock = false;
|
||||
self.playback.audio_retry_at = 0.0;
|
||||
}
|
||||
|
||||
/// Start the clock (and audio) once the first frames are ready or the preroll timed out.
|
||||
@@ -1008,7 +1018,7 @@ impl FilmcraftApp {
|
||||
self.playback.anchor_time = now;
|
||||
self.playback.anchor_tick = self.session.playhead();
|
||||
self.playback.audio_seen = (0, now);
|
||||
self.start_audio();
|
||||
self.start_audio(now);
|
||||
// Audio Track Mixer: an automation pass runs while playing forward in real time
|
||||
if (self.playback.speed - 1.0).abs() < 1e-9 && !self.session.mixrec.active() {
|
||||
let t = self.session.playhead();
|
||||
@@ -1060,6 +1070,7 @@ impl FilmcraftApp {
|
||||
}
|
||||
self.scrub.device_stopped();
|
||||
self.playback.audio_clock = false;
|
||||
self.playback.audio_retry_at = 0.0;
|
||||
if self.session.mixrec.active() {
|
||||
let t = self.session.playhead();
|
||||
if let Err(e) = self.session.execute("mixer.recordStop", json!({"time": t.0})) {
|
||||
@@ -1070,7 +1081,7 @@ impl FilmcraftApp {
|
||||
panels::voiceover::on_stop(self);
|
||||
}
|
||||
|
||||
fn start_audio(&mut self) {
|
||||
fn start_audio(&mut self, now: f64) {
|
||||
let Some(seq_id) = self.session.state.active_sequence else { return };
|
||||
let start_tick = self.session.playhead();
|
||||
let Some(a) = self.audio.as_mut() else { return };
|
||||
@@ -1079,6 +1090,7 @@ impl FilmcraftApp {
|
||||
// either the original rate conversion or pitch-corrected program audio.
|
||||
let Some((mix_rate, mix)) = shuttle::mix(&self.session, seq_id, sr, self.playback.speed) else {
|
||||
a.stop();
|
||||
self.playback.audio_retry_at = 0.0;
|
||||
return;
|
||||
};
|
||||
let cursor = start_tick.to_units_floor(i64::from(mix_rate));
|
||||
@@ -1098,6 +1110,7 @@ impl FilmcraftApp {
|
||||
match a.start(fill) {
|
||||
Ok(_) => {
|
||||
self.playback.audio_clock = true;
|
||||
self.playback.audio_retry_at = 0.0;
|
||||
if let Some(note) = self.audio.as_ref().and_then(|audio| audio.note()) {
|
||||
self.ui.status = note;
|
||||
}
|
||||
@@ -1105,6 +1118,7 @@ impl FilmcraftApp {
|
||||
Err(e) => {
|
||||
log::warn!("audio output unavailable: {e}");
|
||||
self.playback.audio_clock = false;
|
||||
self.playback.audio_retry_at = now + AUDIO_RETRY_S;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1127,6 +1141,15 @@ impl FilmcraftApp {
|
||||
if self.playback.anchor_time < 0.0 {
|
||||
self.playback.anchor_time = now;
|
||||
}
|
||||
if !self.playback.audio_clock && self.playback.audio_retry_at > 0.0 && now >= self.playback.audio_retry_at {
|
||||
// The output failed mid-play: open it again where the wall clock got to (what
|
||||
// pause/play does by hand), so a recovering device brings the sound back.
|
||||
self.playback.audio_retry_at = now + AUDIO_RETRY_S;
|
||||
self.playback.anchor_tick = self.session.playhead();
|
||||
self.playback.anchor_time = now;
|
||||
self.playback.audio_seen = (0, now);
|
||||
self.start_audio(now);
|
||||
}
|
||||
let rate = self.session.sequence_rate();
|
||||
if self.playback.audio_clock
|
||||
&& let Some(audio) = self.audio.as_mut()
|
||||
@@ -1140,6 +1163,7 @@ impl FilmcraftApp {
|
||||
self.playback.anchor_tick = self.session.playhead();
|
||||
self.playback.anchor_time = now;
|
||||
self.playback.audio_clock = false;
|
||||
self.playback.audio_retry_at = now + AUDIO_RETRY_S;
|
||||
if let Some(audio) = self.audio.as_mut() {
|
||||
audio.stop();
|
||||
}
|
||||
@@ -1818,7 +1842,8 @@ impl FilmcraftApp {
|
||||
self.handle_drops(&ctx);
|
||||
if let Some(rx) = self.command_inbox.take() {
|
||||
while let Ok(id) = rx.try_recv() {
|
||||
if self.control_input_only {
|
||||
// control-only mode ignores native menu commands, but Quit stays available (#1943)
|
||||
if self.control_input_only && id != "app.quit" {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) {
|
||||
@@ -2474,7 +2499,7 @@ mod audio_recovery_tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
struct Device {
|
||||
@@ -2721,6 +2746,74 @@ mod audio_recovery_tests {
|
||||
);
|
||||
app.stop();
|
||||
}
|
||||
|
||||
/// A device whose stream dies mid-play (`ticking`) or whose open fails (`opens`).
|
||||
struct Reviving {
|
||||
starts: Arc<AtomicUsize>,
|
||||
opens: Arc<AtomicBool>,
|
||||
ticking: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AudioOut for Reviving {
|
||||
fn start(&mut self, _: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
|
||||
self.starts.fetch_add(1, Ordering::Relaxed);
|
||||
if self.opens.load(Ordering::Relaxed) { Ok(48_000) } else { Err("device unavailable".into()) }
|
||||
}
|
||||
fn stop(&mut self) {}
|
||||
fn sample_rate(&self) -> u32 {
|
||||
48_000
|
||||
}
|
||||
fn played_frames(&self) -> Option<u64> {
|
||||
self.ticking.load(Ordering::Relaxed).then_some(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lost_output_is_reopened_and_the_sound_returns() {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
let starts = Arc::new(AtomicUsize::new(0));
|
||||
let ticking = Arc::new(AtomicBool::new(true));
|
||||
app.audio = Some(Box::new(Reviving { starts: starts.clone(), opens: Arc::new(AtomicBool::new(true)), ticking: ticking.clone() }));
|
||||
app.play(1.0);
|
||||
app.end_preroll(0.0);
|
||||
assert!(app.playback.audio_clock && starts.load(Ordering::Relaxed) == 1);
|
||||
// the stream dies: the device stops reporting a playback clock
|
||||
ticking.store(false, Ordering::Relaxed);
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx()));
|
||||
output.textures_delta.clear();
|
||||
assert!(!app.playback.audio_clock);
|
||||
// the device recovers before the next retry: playback reopens it itself
|
||||
ticking.store(true, Ordering::Relaxed);
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0 + AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx()));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is reopened without pause/play");
|
||||
assert!(app.playback.audio_clock, "the fresh stream is the master clock again");
|
||||
app.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_that_appears_mid_playback_starts_sounding() {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
let starts = Arc::new(AtomicUsize::new(0));
|
||||
let opens = Arc::new(AtomicBool::new(false));
|
||||
app.audio = Some(Box::new(Reviving { starts: starts.clone(), opens: opens.clone(), ticking: Arc::new(AtomicBool::new(true)) }));
|
||||
app.play(1.0);
|
||||
app.end_preroll(0.0);
|
||||
assert!(!app.playback.audio_clock, "no device yet: playing on the wall clock");
|
||||
// headphones plug in while playing: the next retry opens them
|
||||
opens.store(true, Ordering::Relaxed);
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx()));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is retried without pause/play");
|
||||
assert!(app.playback.audio_clock);
|
||||
app.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a shortcut chord must be left alone because a text field is focused.
|
||||
|
||||
@@ -8,7 +8,7 @@ use filmcraft_render::SourceProvider;
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{AUDIO_STALL_S, FilmcraftApp, Playback};
|
||||
use crate::{AUDIO_RETRY_S, AUDIO_STALL_S, FilmcraftApp, Playback};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SourcePlayback {
|
||||
@@ -96,7 +96,7 @@ impl FilmcraftApp {
|
||||
self.source_playback.shown = time;
|
||||
self.source_playback.clock = Playback { playing: true, speed, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() };
|
||||
self.source_playback.clock.meter.start(speed);
|
||||
self.start_source_audio();
|
||||
self.start_source_audio(0.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -113,18 +113,20 @@ impl FilmcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn start_source_audio(&mut self) {
|
||||
fn start_source_audio(&mut self, now: f64) {
|
||||
self.source_playback.clock.audio_clock = false;
|
||||
if (self.source_playback.clock.speed - 1.0).abs() > 1e-9 {
|
||||
if let Some(audio) = self.audio.as_mut() {
|
||||
audio.stop();
|
||||
}
|
||||
self.source_playback.clock.audio_retry_at = 0.0;
|
||||
return;
|
||||
}
|
||||
let Some(item) = self.source_playback.item else { return };
|
||||
let Some(view) = source_view(&self.session, item) else { return };
|
||||
let Some(media) = self.session.project.item(view.media).and_then(|i| i.as_media()) else { return };
|
||||
if !media.info.has_audio() {
|
||||
self.source_playback.clock.audio_retry_at = 0.0;
|
||||
return;
|
||||
}
|
||||
let project = self.session.project.clone();
|
||||
@@ -185,8 +187,14 @@ impl FilmcraftApp {
|
||||
})
|
||||
};
|
||||
match audio.start(fill) {
|
||||
Ok(_) => self.source_playback.clock.audio_clock = true,
|
||||
Err(e) => self.ui.status = format!("Source audio output failed: {e}"),
|
||||
Ok(_) => {
|
||||
self.source_playback.clock.audio_clock = true;
|
||||
self.source_playback.clock.audio_retry_at = 0.0;
|
||||
}
|
||||
Err(e) => {
|
||||
self.ui.status = format!("Source audio output failed: {e}");
|
||||
self.source_playback.clock.audio_retry_at = now + AUDIO_RETRY_S;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +219,16 @@ impl FilmcraftApp {
|
||||
self.source_playback.clock.anchor_tick = time;
|
||||
self.source_playback.clock.anchor_time = now;
|
||||
self.source_playback.clock.audio_seen = (0, now);
|
||||
self.start_source_audio();
|
||||
self.start_source_audio(now);
|
||||
}
|
||||
if !self.source_playback.clock.audio_clock && self.source_playback.clock.audio_retry_at > 0.0 && now >= self.source_playback.clock.audio_retry_at {
|
||||
// The output failed mid-play: open it again where the wall clock got to, so a
|
||||
// recovering device brings the sound back without a pause/play.
|
||||
self.source_playback.clock.audio_retry_at = now + AUDIO_RETRY_S;
|
||||
self.source_playback.clock.anchor_tick = time;
|
||||
self.source_playback.clock.anchor_time = now;
|
||||
self.source_playback.clock.audio_seen = (0, now);
|
||||
self.start_source_audio(now);
|
||||
}
|
||||
if self.source_playback.clock.audio_clock
|
||||
&& let Some(audio) = self.audio.as_mut()
|
||||
@@ -233,6 +250,10 @@ impl FilmcraftApp {
|
||||
clock.anchor_tick = time;
|
||||
clock.anchor_time = now;
|
||||
clock.audio_clock = false;
|
||||
// A dead stream is reopened on a timer; a stalled one keeps the wall clock (#136).
|
||||
if reading.is_none() {
|
||||
clock.audio_retry_at = now + AUDIO_RETRY_S;
|
||||
}
|
||||
if let Some(audio) = self.audio.as_mut() {
|
||||
audio.stop();
|
||||
}
|
||||
@@ -275,7 +296,7 @@ impl FilmcraftApp {
|
||||
self.source_playback.clock.anchor_tick = time;
|
||||
self.source_playback.clock.anchor_time = now;
|
||||
self.source_playback.clock.audio_seen = (0, now);
|
||||
self.start_source_audio();
|
||||
self.start_source_audio(now);
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -347,6 +347,26 @@ fn quitting_with_unsaved_changes_asks_to_save() {
|
||||
assert!(!d.sent(&egui::ViewportCommand::CancelClose), "the confirmed quit goes through");
|
||||
}
|
||||
|
||||
/// #1943: the macOS menu bar's Quit (Cmd+Q) sends `app.quit` to the command inbox; with unsaved
|
||||
/// changes that closes the window through the prompt instead of quitting outright.
|
||||
#[test]
|
||||
fn native_menu_quit_with_unsaved_changes_asks_to_save() {
|
||||
let mut d = Driver::new();
|
||||
d.edit();
|
||||
let (tx, rx) = channel();
|
||||
d.app().command_inbox = Some(rx);
|
||||
tx.send("app.quit".to_string()).unwrap();
|
||||
d.frames(1);
|
||||
assert!(d.sent(&egui::ViewportCommand::Close), "Quit asks the window to close");
|
||||
assert!(!d.app().quit_confirmed, "nothing is confirmed yet");
|
||||
// the window's close request is cancelled and the prompt asks instead
|
||||
d.request_close();
|
||||
assert!(d.sent(&egui::ViewportCommand::CancelClose), "the window must stay open");
|
||||
d.frames(2);
|
||||
assert!(d.has("quit.save") && d.has("quit.dontSave") && d.has("quit.cancel"));
|
||||
assert!(d.app().session.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_save_on_a_new_project_uses_the_save_dialog_and_scripted_quit_does_not_ask() {
|
||||
let path = std::env::temp_dir().join(format!("fc-quit-save-{}.fcproj", std::process::id()));
|
||||
|
||||
@@ -280,3 +280,22 @@ fn modified_control_drag_keeps_shift_until_release_and_clears_it_afterward() {
|
||||
d.frames(3, false);
|
||||
assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE);
|
||||
}
|
||||
|
||||
/// CodeRabbit on #2006: in control-only mode the native menu's commands are ignored, but its Quit
|
||||
/// item (now `app.quit` through the command inbox, #1943) must still close the window, as the
|
||||
/// predefined Quit item did.
|
||||
#[test]
|
||||
fn native_quit_still_quits_in_control_only_mode() {
|
||||
let mut d = Driver::new(true);
|
||||
let (tx, rx) = channel();
|
||||
d.h.state_mut().command_inbox = Some(rx);
|
||||
// another menu command is ignored
|
||||
let project = d.h.state().session.project.clone();
|
||||
tx.send("file.newBin".to_string()).unwrap();
|
||||
d.frames(2, true);
|
||||
assert!(Arc::ptr_eq(&project, &d.h.state().session.project), "menu commands do not run in control-only mode");
|
||||
tx.send("app.quit".to_string()).unwrap();
|
||||
d.frames(1, true);
|
||||
let closing = d.h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Close));
|
||||
assert!(closing, "Quit asks the window to close");
|
||||
}
|
||||
|
||||
@@ -11,3 +11,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
|
||||
//! ffmpeg-generated VP8 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use filmcraft_vp8::Decoder;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// (name, encoder args, pix_fmt)
|
||||
const SEEDS: &[(&str, &str, &str)] = &[
|
||||
("vp8_fz_default", "-c:v libvpx -g 4 -b:v 300k", "yuv420p"),
|
||||
("vp8_fz_partitions", "-c:v libvpx -g 6 -b:v 300k -slices 8 -auto-alt-ref 0 -lag-in-frames 0 -error-resilient 1", "yuv420p"),
|
||||
];
|
||||
|
||||
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let Some(h) = data.get(6..8) else { return out };
|
||||
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
|
||||
while let Some(hd) = data.get(pos..pos + 12) {
|
||||
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
|
||||
pos += 12;
|
||||
let end = pos.saturating_add(size).min(data.len());
|
||||
out.push(data[pos..end].to_vec());
|
||||
pos = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
|
||||
let out = filmcraft_testkit::fixtures_dir("vp8").join(format!("{name}.ivf"));
|
||||
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
let a: Vec<&str> = args.split_whitespace().collect();
|
||||
matches!(
|
||||
Command::new(ff)
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
|
||||
.args(a)
|
||||
.args(["-f", "ivf"])
|
||||
.arg(tmp)
|
||||
.stdin(Stdio::null())
|
||||
.status(),
|
||||
Ok(s) if s.success()
|
||||
)
|
||||
})?;
|
||||
Some(ivf_frames(&std::fs::read(p).ok()?))
|
||||
}
|
||||
|
||||
static PANICS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test.
|
||||
fn count_panics() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
PANICS.fetch_add(1, Ordering::SeqCst);
|
||||
eprintln!("PANIC: {info}");
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
fn run_all(frames: &[Vec<u8>], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
count_panics();
|
||||
let before = PANICS.load(Ordering::SeqCst);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for _ in 0..1 {
|
||||
let mut dec = Decoder::new();
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let _ = dec.decode(f, i as i64);
|
||||
}
|
||||
}
|
||||
}));
|
||||
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
|
||||
}
|
||||
|
||||
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
|
||||
fn iters() -> usize {
|
||||
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP8 fuzz") else { return };
|
||||
for &(name, args, pix) in SEEDS {
|
||||
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx") {
|
||||
filmcraft_testkit::skip("VP8 fuzz", &format!("this ffmpeg has no libvpx encoder (seed {name})"));
|
||||
continue;
|
||||
}
|
||||
let Some(seed) = make(&ff, name, args, pix) else {
|
||||
panic!("ffmpeg has libvpx but could not encode the {name} seed");
|
||||
};
|
||||
if seed.is_empty() {
|
||||
filmcraft_testkit::skip("VP8 fuzz", &format!("the {name} seed has no frames"));
|
||||
continue;
|
||||
}
|
||||
// truncate each of the first frames at every length
|
||||
for fi in 0..seed.len().min(3) {
|
||||
let len = seed[fi].len();
|
||||
for n in (0..len).step_by((len / 200).max(1)) {
|
||||
let mut d = seed.clone();
|
||||
d[fi].truncate(n);
|
||||
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
|
||||
}
|
||||
}
|
||||
// header region of every frame: every byte to a few values, every bit
|
||||
for fi in 0..seed.len() {
|
||||
for p in 0..seed[fi].len().min(96) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[fi][p] = v;
|
||||
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[fi][p] ^= 1 << b;
|
||||
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
|
||||
for i in 0..iters() {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(6) {
|
||||
let fi = rng.below(d.len());
|
||||
if d[fi].is_empty() {
|
||||
continue;
|
||||
}
|
||||
let p = rng.below(d[fi].len());
|
||||
if rng.below(2) == 0 {
|
||||
d[fi][p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[fi][p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run_all(&d, &format!("{name} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,18 @@ emitted flat (row-major, as printed) with its element count checked against the
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
|
||||
def _spec_path(arg):
|
||||
"""Resolve a CLI path argument and require it to be an existing regular file."""
|
||||
path = Path(arg).resolve()
|
||||
if not path.is_file():
|
||||
sys.exit(f"not a readable file: {arg}")
|
||||
return path
|
||||
|
||||
|
||||
text = open(_spec_path(sys.argv[1]), encoding="utf-8").read()
|
||||
cut = text.find("\n20. Attachment One")
|
||||
if cut < 0:
|
||||
sys.exit("section 20 not found")
|
||||
|
||||
@@ -19,3 +19,4 @@ thiserror = { workspace = true }
|
||||
rayon = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
|
||||
//! ffmpeg-generated VP9 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use filmcraft_vp9::Decoder;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// (name, encoder args, pix_fmt)
|
||||
const SEEDS: &[(&str, &str, &str)] = &[
|
||||
("vp9_fz_default", "-c:v libvpx-vp9 -g 4 -b:v 300k -lag-in-frames 0", "yuv420p"),
|
||||
// libvpx needs 256 px per tile column, so two columns need 512 px
|
||||
("vp9_fz_2tiles_10bit", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -tile-columns 1 -profile:v 2 -s 512x48", "yuv420p10le"),
|
||||
("vp9_fz_444", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -profile:v 1", "yuv444p"),
|
||||
("vp9_fz_altref", "-c:v libvpx-vp9 -g 6 -b:v 300k -auto-alt-ref 1 -lag-in-frames 5", "yuv420p"),
|
||||
];
|
||||
|
||||
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let Some(h) = data.get(6..8) else { return out };
|
||||
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
|
||||
while let Some(hd) = data.get(pos..pos + 12) {
|
||||
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
|
||||
pos += 12;
|
||||
let end = pos.saturating_add(size).min(data.len());
|
||||
out.push(data[pos..end].to_vec());
|
||||
pos = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
|
||||
let out = filmcraft_testkit::fixtures_dir("vp9").join(format!("{name}.ivf"));
|
||||
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
let a: Vec<&str> = args.split_whitespace().collect();
|
||||
matches!(
|
||||
Command::new(ff)
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
|
||||
.args(a)
|
||||
.args(["-f", "ivf"])
|
||||
.arg(tmp)
|
||||
.stdin(Stdio::null())
|
||||
.status(),
|
||||
Ok(s) if s.success()
|
||||
)
|
||||
})?;
|
||||
Some(ivf_frames(&std::fs::read(p).ok()?))
|
||||
}
|
||||
|
||||
static PANICS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test.
|
||||
fn count_panics() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
PANICS.fetch_add(1, Ordering::SeqCst);
|
||||
eprintln!("PANIC: {info}");
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
fn run_all(frames: &[Vec<u8>], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
count_panics();
|
||||
let before = PANICS.load(Ordering::SeqCst);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for threads in [1, 3] {
|
||||
let mut dec = Decoder::with_threads(threads);
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let _ = dec.decode(f, i as i64);
|
||||
}
|
||||
let _ = dec.flush();
|
||||
}
|
||||
}));
|
||||
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
|
||||
}
|
||||
|
||||
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
|
||||
fn iters() -> usize {
|
||||
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP9 fuzz") else { return };
|
||||
for &(name, args, pix) in SEEDS {
|
||||
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx-vp9") {
|
||||
filmcraft_testkit::skip("VP9 fuzz", &format!("this ffmpeg has no libvpx-vp9 encoder (seed {name})"));
|
||||
continue;
|
||||
}
|
||||
let Some(seed) = make(&ff, name, args, pix) else {
|
||||
panic!("ffmpeg has libvpx-vp9 but could not encode the {name} seed");
|
||||
};
|
||||
if seed.is_empty() {
|
||||
filmcraft_testkit::skip("VP9 fuzz", &format!("the {name} seed has no frames"));
|
||||
continue;
|
||||
}
|
||||
// truncate each of the first frames at every length
|
||||
for fi in 0..seed.len().min(3) {
|
||||
let len = seed[fi].len();
|
||||
for n in (0..len).step_by((len / 200).max(1)) {
|
||||
let mut d = seed.clone();
|
||||
d[fi].truncate(n);
|
||||
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
|
||||
}
|
||||
}
|
||||
// header region of every frame: every byte to a few values, every bit
|
||||
for fi in 0..seed.len() {
|
||||
for p in 0..seed[fi].len().min(96) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[fi][p] = v;
|
||||
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[fi][p] ^= 1 << b;
|
||||
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
|
||||
for i in 0..iters() {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(6) {
|
||||
let fi = rng.below(d.len());
|
||||
if d[fi].is_empty() {
|
||||
continue;
|
||||
}
|
||||
let p = rng.below(d[fi].len());
|
||||
if rng.below(2) == 0 {
|
||||
d[fi][p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[fi][p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run_all(&d, &format!("{name} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,18 @@ element count checked against the declared dimensions.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read().replace("−", "-").replace("–", "-")
|
||||
|
||||
def _spec_path(arg):
|
||||
"""Resolve a CLI path argument and require it to be an existing regular file."""
|
||||
path = Path(arg).resolve()
|
||||
if not path.is_file():
|
||||
sys.exit(f"not a readable file: {arg}")
|
||||
return path
|
||||
|
||||
|
||||
text = open(_spec_path(sys.argv[1]), encoding="utf-8").read().replace("−", "-").replace("–", "-")
|
||||
lines = [l for l in text.split("\n") if "Copyright" not in l and "VP9 Bitstream & Decoding Process Specification" not in l]
|
||||
text = "\n".join(lines)
|
||||
|
||||
|
||||
+15
-12
@@ -23,6 +23,7 @@ and write wherever the user can.
|
||||
|
||||
```sh
|
||||
cargo build --release -p filmcraft-cli -p filmcraft
|
||||
export FILMCRAFT_CONTROL_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
|
||||
./target/release/filmcraft --control 9876 & # only for the bridge server
|
||||
claude # approve the project MCP servers when asked
|
||||
```
|
||||
@@ -134,29 +135,31 @@ progress and stop it:
|
||||
## 2. Control channel
|
||||
|
||||
`filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT=9876`) listens on `127.0.0.1` only. Send one
|
||||
JSON request per line and get one JSON reply per line. Full method table:
|
||||
JSON request per line and get one JSON reply per line. Set a fresh `FILMCRAFT_CONTROL_TOKEN` for the
|
||||
desktop session as above; the CLI / MCP bridge must inherit the same variable. Raw requests must
|
||||
include it as `token` on every line (replace the placeholders below). Full setup and method table:
|
||||
[control-protocol.md](control-protocol.md).
|
||||
|
||||
```jsonc
|
||||
{"id":1,"method":"engine.commands"}
|
||||
{"id":2,"method":"engine.execute","params":{"command":"file.openDemoProject"}}
|
||||
{"id":3,"method":"engine.execute","params":{"command":"playhead.set","params":{"seconds":2}}}
|
||||
{"id":4,"method":"engine.execute","params":{"command":"sequence.addEdit"}}
|
||||
{"id":5,"method":"engine.execute","params":{"command":"effects.apply","params":{"effect":"Gaussian Blur"}}}
|
||||
{"id":6,"method":"ui.elements","params":{"prefix":"tools."}}
|
||||
{"id":7,"method":"ui.click","params":{"id":"tools.Razor"}}
|
||||
{"id":8,"method":"ui.key","params":{"key":"Cmd+Z"}}
|
||||
{"id":9,"method":"ui.screenshot","params":{"path":"/tmp/timeline.png","panel":"Timeline"}}
|
||||
{"id":1,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.commands"}
|
||||
{"id":2,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.execute","params":{"command":"file.openDemoProject"}}
|
||||
{"id":3,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.execute","params":{"command":"playhead.set","params":{"seconds":2}}}
|
||||
{"id":4,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.execute","params":{"command":"sequence.addEdit"}}
|
||||
{"id":5,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.execute","params":{"command":"effects.apply","params":{"effect":"Gaussian Blur"}}}
|
||||
{"id":6,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"ui.elements","params":{"prefix":"tools."}}
|
||||
{"id":7,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"ui.click","params":{"id":"tools.Razor"}}
|
||||
{"id":8,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"ui.key","params":{"key":"Cmd+Z"}}
|
||||
{"id":9,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"ui.screenshot","params":{"path":"/tmp/timeline.png","panel":"Timeline"}}
|
||||
```
|
||||
|
||||
Replies look like `{"id":4,"ok":true,"result":{"cuts":2}}` or `{"id":7,"ok":false,"error":"…"}`.
|
||||
Minimal client:
|
||||
|
||||
```python
|
||||
import json, socket
|
||||
import json, os, socket
|
||||
s = socket.create_connection(("127.0.0.1", 9876)); f = s.makefile("rw")
|
||||
def call(method, **params):
|
||||
f.write(json.dumps({"id": 1, "method": method, "params": params}) + "\n"); f.flush()
|
||||
f.write(json.dumps({"id": 1, "token": os.environ["FILMCRAFT_CONTROL_TOKEN"], "method": method, "params": params}) + "\n"); f.flush()
|
||||
return json.loads(f.readline())
|
||||
print(call("engine.execute", command="sequence.inspect"))
|
||||
```
|
||||
|
||||
@@ -39,6 +39,7 @@ cargo xtask web --serve 8765 # the web app on http://12
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `FILMCRAFT_CONTROL_PORT` | Same as `--control <port>` |
|
||||
| `FILMCRAFT_CONTROL_TOKEN` | Required for `--control`: 64 random hex characters, inherited by authorized clients; [setup](control-protocol.md) |
|
||||
| `FILMCRAFT_CPU_COMPOSITE=1` | Disables the GPU compositor |
|
||||
| `FILMCRAFT_DEV_DESKTOP_ENTRY=1` | Linux, opt-in: a run from a source tree installs a user-level desktop entry (and the icons from `assets/app-icon`) so the taskbar shows the FilmCraft icon. Nothing is written without it; packaged runs never write one |
|
||||
| `FILMCRAFT_DATA_DIR` | Same as `--data-dir <dir>`: auto-save, crash recovery, settings and logs |
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
## Desktop control channel
|
||||
`filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT`) listens on `127.0.0.1:<port>` (loopback only).
|
||||
The listener requires `FILMCRAFT_CONTROL_TOKEN`: 64 random hex characters shared with authorized
|
||||
clients through their inherited environment. Generate a fresh token for each desktop session, then
|
||||
start the desktop and bridge from that environment:
|
||||
|
||||
```sh
|
||||
export FILMCRAFT_CONTROL_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
|
||||
filmcraft --control 9876 &
|
||||
filmcraft-cli mcp --bridge 127.0.0.1:9876
|
||||
```
|
||||
|
||||
The CLI / MCP bridge reads this variable automatically. Keep it private: don't put the value in
|
||||
command-line arguments, logs, checked-in MCP configuration, or shared files. Without a valid value,
|
||||
the desktop leaves the listener closed. Every raw JSON request must include the same top-level
|
||||
`token` string; missing or incorrect credentials get an error and close the connection before any
|
||||
request is dispatched. Loopback alone does not restrict clients to the desktop owner's OS account.
|
||||
|
||||
One JSON request per line → one JSON reply per line. The app opts out of macOS App Nap
|
||||
(`apps/filmcraft/src/app_nap.rs`): a hidden window would otherwise drop the whole process to
|
||||
background priority, and on a busy machine it would stop answering.
|
||||
@@ -13,11 +29,11 @@ nothing sent after it on that connection runs. That covers text that isn't JSON,
|
||||
an object without `method`, invalid UTF-8, and a line longer than 4 MiB. An HTTP request (for example a
|
||||
web page's cross-origin `fetch` to `127.0.0.1:<port>`) therefore can't smuggle a command in its body:
|
||||
its request line is rejected first. At most 16 connections are served at once; further ones get an error
|
||||
line and are closed. Clients that get an error reply should reconnect. The port has no authentication,
|
||||
so only enable it while you use it. Transport: `apps/filmcraft/src/control_server.rs`.
|
||||
line and are closed. Clients that get an error reply should reconnect. Transport:
|
||||
`apps/filmcraft/src/control_server.rs`.
|
||||
|
||||
```json
|
||||
{"id": 1, "method": "engine.execute", "params": {"command": "sequence.addEdit", "params": {"seconds": 3}}}
|
||||
{"id": 1, "token": "<FILMCRAFT_CONTROL_TOKEN>", "method": "engine.execute", "params": {"command": "sequence.addEdit", "params": {"seconds": 3}}}
|
||||
{"id": 1, "ok": true, "result": {"cuts": 2}}
|
||||
```
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
+3
-3
@@ -1,11 +1,11 @@
|
||||
asset: filmcraft-lumetri.png
|
||||
title: README: Lumetri Color panel
|
||||
asset: filmcraft-color-panel.png
|
||||
title: README: color grading panel
|
||||
author: FilmCraft contributors (screenshot); film/footage/music credits below
|
||||
source: original work (screenshot of FilmCraft captured with its ui.screenshot control command)
|
||||
license: MIT OR Apache-2.0 (screenshot); embedded film frames are public domain, music CC0-1.0
|
||||
license-file:
|
||||
added: 2026-09-30 by Claude (agent) for Brandon Thomas
|
||||
notes: Crop of the Lumetri Color panel (Basic Correction, Color Wheels) while grading Charade (1963); shows panel controls only. All UI icons are drawn in code; fonts are Inter and JetBrains Mono (OFL-1.1). Contains no Adobe artwork.
|
||||
notes: Crop of the color grading panel (Basic Correction, Color Wheels) while grading Charade (1963); shows panel controls only. All UI icons are drawn in code; fonts are Inter and JetBrains Mono (OFL-1.1). Contains no Adobe artwork.
|
||||
Media visible in the screenshot (none of it is committed to the repository):
|
||||
- Night of the Living Dead (1968), dir. George A. Romero, Image Ten. US public domain (released without a copyright notice). Source: https://archive.org/details/night-of-the-living-dead-1968-english (Public Domain Mark).
|
||||
- Carnival of Souls (1962), dir. Herk Harvey, Harcourt Productions. US public domain. Source: https://archive.org/details/CarnivalOfSoulsVideoQualityUpgrade (Public Domain Mark).
|
||||
@@ -125,9 +125,10 @@ like Premiere's:
|
||||
| Update it at least every N second(s) | `autoSave.recoveryIntervalSeconds` | 5 (1–600) |
|
||||
|
||||
At each interval, if the project changed since the last auto-save, a copy is written to the
|
||||
`Auto-Save` folder next to the project as `<name>-YYYY-MM-DD_HH-MM-SS.fcproj` (local time). An
|
||||
`Auto-Save` folder next to the project as `<name>-YYYY-MM-DD_HH-MM-SS-<unix seconds>.fcproj` (local time, then the real time that orders
|
||||
the copies, so a clock change at the end of daylight saving time cannot reorder them). An
|
||||
unsaved project uses `<data dir>/Auto-Save/`. After each write the oldest copies beyond the limit are
|
||||
deleted. Only files named exactly `<name>-<timestamp>.fcproj` are ever deleted. An auto-save is an
|
||||
deleted. Only files named exactly `<name>-<timestamp>.fcproj` or `<name>-<timestamp>-<unix seconds>.fcproj` are ever deleted. An auto-save is an
|
||||
ordinary project file: open it with File ▸ Open Project. `file.listAutoSaves` lists them, newest
|
||||
first.
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Roadmap detail
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: milestones moved from ROADMAP.md, Current focus rewritten from the 2026-10-10 re-measure, beta milestones added) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (M8: Fujifilm F-Log / F-Log2 / F-Log2 C colour spaces) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
|
||||
|
||||
Forward-looking plan: milestones with status, the current focus and what's next, with estimates in
|
||||
Opus 5.5 agent-hours (calibration in [target-app-parity.md](target-app-parity.md#calibration)).
|
||||
@@ -68,7 +68,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
| M5 | Effects, keyframes, GPU | 🟡 | All 93 Premiere 26 video effects and 84 transitions (CPU), blend modes and 33 common effects on the GPU (#32, GPU2, GPU4, GPU5), keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | More effects on the GPU (34 ids done incl. Lumetri basic / creative / vignette; GPU export landed #421), GPU masks in the live path, exact Warp Stabilizer (3-D) and Morph Cut | 40–70 |
|
||||
| M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 or QuickTime + own AAC; ProRes, DNxHR, MJPEG, PNG/TIFF/BMP sequences (Premiere-style numbering), GIF, WAV, AIFF; background jobs; M6.5: Export mode parity (frame size / rate / scaling / pixel aspect, profile / level, CBR / VBR 1- and 2-pass, keyframe distance, audio codec / rate / channels / bitrate / sample size, multiplexer, burn-in or sidecar captions, image / name / timecode overlays, video limiter, loudness normalization with true-peak limiter, metadata, ranges, estimated size, summary), 24 built-in presets + user presets with favourites and import/export (Preset Manager), export queue (reorder, cancel, retry, several sequences / ranges), Quick Export; `export.*` commands and `filmcraft-cli export --preset` | Interlaced encoding, 5.1 audio, smart render, publishing destinations | 6–8 |
|
||||
| M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 |
|
||||
| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 |
|
||||
| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log, Fujifilm F-Log / F-Log2 / F-Log2 C from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 |
|
||||
| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4), DNxHD/DNxHR decoder (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2/4:4:4, interlaced, alpha; within IDCT precision of ffmpeg on 22 fixtures) + DNxHR LB/SQ/HQ/HQX/444 encoder and MOV `AVdh` export, AV1 decoder (Main profile, 8/10-bit, all intra/inter tools, loop filters, superres, film grain, intra BC, spatial layers; bit-exact vs libdav1d on 19 SVT fixtures + 22 libaom vectors; MP4 `av01` / WebM `V_AV1` import), MXF import (OP1a/OP-Atom; AVC with POC-ordered B pictures bit-exact, DNxHD/DNxHR, ProRes, PCM/AES3; MPEG-2 reported unsupported; index-table seeking, material package timecode), Ogg Opus/Vorbis (granule seeking, pre-skip, end trimming), image sequences, Broadcast WAV timecode | VP9 frame threading, AV1 threading/SIMD (~4 fps 1080p today), AV1 High/Professional profiles, H.264 High 10 / 4:2:2 and HEVC 4:2:2 in software (G3), E-AC-3, AVI (MPEG-2 decoder and hardware decode have landed) | 30–50 |
|
||||
| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute; graphics templates (own `.fcgt` format, 8 original built-ins, Browse tab with engine-rendered thumbnails, export / install / apply / editable properties; never reads .mogrt), rolls/crawls with ease and pre/postroll, responsive pins and intro/outro, per-character styles, Upgrade Caption to Graphic, source graphics, Replace Fonts; MCC (608+708), EBU STL, TTML/IMSC1, DFXP (frame-exact, property-tested) | 608/708 embedding in video streams, speech-to-text | 12–18 |
|
||||
| M11 | Interchange & project management | ✅ | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO; offline media (own slate) + Link Media (fingerprint-checked relink, folder remap, search, Align Timecode, Make Offline); proxies (ProRes Proxy/LT, H.264 ¼/½ background jobs, attach/detach/reconnect, monitor toggle, export full-res) + ingest (copy/transcode/proxies); Project Manager (collect, consolidate + transcode with handles, size estimate) | Rename media to clip names, image-sequence conversion, Media Browser-driven relink, smart (cross-drive) path tracking | 1–2 |
|
||||
|
||||
@@ -116,7 +116,7 @@ if has appimage; then
|
||||
if [ -z "$TOOL" ]; then
|
||||
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
|
||||
if [ ! -x "$TOOL" ]; then
|
||||
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
chmod +x "$TOOL"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
sonar.projectKey=storytold_filmcraft
|
||||
sonar.organization=storytold
|
||||
sonar.coverage.exclusions=**/tools/**,**/docs/**,**/examples/**
|
||||
Reference in New Issue
Block a user