mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-24 10:10:05 -06:00
Merge branch 'main' into fix/h1135-scc-timecode-parser-accepts-sign
This commit is contained in:
@@ -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
|
||||
Generated
+58
-54
@@ -1679,7 +1679,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.10.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.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1722,7 +1723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1730,7 +1731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-apv"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1740,14 +1741,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
@@ -1763,7 +1764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1774,21 +1775,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-avi"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
@@ -1801,11 +1802,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
@@ -1827,7 +1828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
@@ -1865,7 +1866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1873,7 +1874,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dng"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-dng",
|
||||
"rayon",
|
||||
@@ -1882,7 +1883,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1892,7 +1893,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
@@ -1905,7 +1906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"filmcraft-audio-dsp",
|
||||
@@ -1940,7 +1941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-apv",
|
||||
@@ -1971,7 +1972,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-flac"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1980,7 +1981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.10.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.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
@@ -2003,7 +2005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2011,7 +2013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -2028,7 +2030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
@@ -2047,7 +2049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2057,7 +2059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2067,7 +2069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2077,7 +2079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
@@ -2094,7 +2096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2105,7 +2107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
@@ -2113,7 +2115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-dng",
|
||||
@@ -2129,7 +2131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpa"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2138,7 +2140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
@@ -2147,7 +2149,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2155,28 +2157,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-platform"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-codecs",
|
||||
@@ -2202,7 +2204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
@@ -2216,7 +2218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2226,7 +2228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -2246,7 +2248,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
@@ -2255,7 +2257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2273,14 +2275,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
@@ -2292,7 +2294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2300,7 +2302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-tts"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2314,7 +2316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-tts-text"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2322,7 +2324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"craft-ui",
|
||||
@@ -2361,23 +2363,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp8"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7402,7 +7406,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.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.10.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
</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">
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"?>
|
||||
|
||||
+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
|
||||
|
||||
+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");
|
||||
|
||||
@@ -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"))) }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -1002,6 +1011,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.
|
||||
@@ -1010,7 +1020,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();
|
||||
@@ -1062,6 +1072,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})) {
|
||||
@@ -1072,7 +1083,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 };
|
||||
@@ -1081,6 +1092,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));
|
||||
@@ -1100,6 +1112,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;
|
||||
}
|
||||
@@ -1107,6 +1120,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1129,6 +1143,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()
|
||||
@@ -1142,6 +1165,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();
|
||||
}
|
||||
@@ -1821,7 +1845,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!({})) {
|
||||
@@ -2477,7 +2502,7 @@ mod audio_recovery_tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
struct Device {
|
||||
@@ -2724,6 +2749,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()));
|
||||
|
||||
@@ -275,3 +275,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}}
|
||||
```
|
||||
|
||||
|
||||
@@ -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