diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 000000000..63e594472
--- /dev/null
+++ b/.config/nextest.toml
@@ -0,0 +1,14 @@
+# `cargo nextest run` (used by `cargo xtask ci`) runs every test in a process of its own, in parallel.
+
+[test-groups]
+# NVENC sessions are a limited resource on consumer GPUs.
+nvenc = { max-threads = 1 }
+
+[[profile.default.overrides]]
+filter = "package(filmcraft-platform) and (binary(/nvenc/) or test(/nvenc/))"
+test-group = "nvenc"
+
+# A throughput gate: it measures one core's speed, so nothing else runs beside it.
+[[profile.default.overrides]]
+filter = "package(filmcraft-render) and binary(mixer_perf)"
+threads-required = "num-test-threads"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..5df1ae3a3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,30 @@
+name: CI
+
+# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
+# suite's shared gate workflow in craft-common-actions.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# A newer push to a PR branch cancels its older run; every push to main runs to the end.
+concurrency:
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ gate:
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ # Same craft-fonts commit as release.yml.
+ craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ timeout-minutes: 120
+ packages: >-
+ 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
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 0139df440..6018ad42c 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -5,17 +5,19 @@
# golden-image tests need tools and a GPU the VM doesn't have.)
name: FreeBSD
-# The VM build takes an hour or more, so it runs nightly on main rather than on every push or
-# dependency change: a drifted dependency still shows up within a day. PRs that change the
-# FreeBSD packaging itself still run it.
+# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
+# request: a drifted dependency still shows up within a day, and always before a release ships.
+# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
- schedule:
- - cron: "37 2 * * *"
+ push:
+ branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
+ schedule:
+ - cron: "37 2 * * *"
workflow_dispatch:
concurrency:
@@ -30,6 +32,6 @@ jobs:
# 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/freebsd.yml@main
with:
- craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
skip-tests: true
run-package-check: true
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 18392587a..2fe7cd3ac 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,16 +18,44 @@ concurrency:
cancel-in-progress: true
jobs:
- eval:
+ git-sources:
+ name: Git dependencies come from storytold
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- - name: nix flake check --no-build
+ - name: Git dependencies come from storytold
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
+ set -euo pipefail
+ test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
+ # Source replacement, patches and path overrides in cargo config never show up
+ # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
+ # Cargo still reads the legacy .cargo/config.
+ python3 - <<'PY'
+ import os, sys, tomllib
+ for path in (".cargo/config", ".cargo/config.toml"):
+ if not os.path.exists(path):
+ continue
+ config = tomllib.load(open(path, "rb"))
+ replaced = [name for name, source in config.get("source", {}).items()
+ if isinstance(source, dict) and "replace-with" in source]
+ if replaced or "patch" in config or "paths" in config:
+ print(f"{path} replaces, patches or overrides a source")
+ sys.exit(1)
+ PY
+ # Fails when Cargo.lock is out of date with the manifests.
+ cargo metadata --locked --format-version 1 > /dev/null
+ python3 - <<'PY'
+ import sys, tomllib
+ lock = tomllib.load(open("Cargo.lock", "rb"))
+ bad = sorted({p["source"] for p in lock.get("package", [])
+ if p.get("source", "").startswith("git+")
+ and not p["source"].startswith("git+https://github.com/storytold/")})
+ if bad:
+ print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
+ sys.exit(1)
+ PY
+
+ 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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 436210aef..711a108d1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,7 +24,7 @@ jobs:
with:
app-name: filmcraft
app-title: FilmCraft
- craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 000000000..e78fe1ea3
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,21 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ # A push to main is the run that publishes coverage; superseded runs there would never finish.
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+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
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index 7666423a1..7934c954d 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -4,19 +4,18 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
+# The package build takes about 15 minutes, so it runs nightly on main and on pushes to `release`
+# rather than on every push to main: a dependency or runner-image change still shows up within a
+# day. PRs that change the Windows packaging itself still run it.
on:
+ schedule:
+ - cron: "41 4 * * *"
push:
- branches: [main]
+ branches: [release]
pull_request:
paths:
- - Cargo.toml
- - Cargo.lock
- - "**/Cargo.toml"
- - "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
- schedule:
- - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
diff --git a/AGENTS.md b/AGENTS.md
index 0683cb87a..3f863866f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -127,6 +127,8 @@ See [§0](#0-never-crash). It outranks every other engineering rule.
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
+
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index b952a98bc..ef7150f1f 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -131,6 +131,10 @@ carry each font's licence as `OFL-.txt`.
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 |
| Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
| BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
+| Noto Sans CJK SC Regular (Google, Adobe) | Chinese UI text and media names; Chinese fallback for titles (native builds) | OFL-1.1 |
+| Noto Sans Arabic Regular (Google) | Arabic UI text; Arabic fallback for titles (native builds) | OFL-1.1 |
+| Noto Sans Thai Regular (Google) | Thai fallback for titles (native builds) | OFL-1.1 |
+| Noto Sans Georgian Regular (Google) | Georgian fallback for titles (native builds) | OFL-1.1 |
## First-party brand marks
diff --git a/CLAUDE.md b/CLAUDE.md
index 2ecdacc20..d28a9b06b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,6 +16,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback.
- **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`.
- **Exact time:** all time is `filmcraft_time::Tick` (254 016 000 000/s). Never use f64 seconds for edit math.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id.
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde so the control channel can read/set it.
- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm` (checks L0–L4 crates). `cargo xtask ci` runs them all.
diff --git a/Cargo.lock b/Cargo.lock
index 2dd0090a9..9ff01577b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1016,6 +1016,14 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+[[package]]
+name = "craft-media"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1679,7 +1687,7 @@ dependencies = [
[[package]]
name = "filmcraft"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"arboard",
"chrono",
@@ -1704,6 +1712,7 @@ dependencies = [
"pollster 0.4.0",
"rfd",
"serde_json",
+ "subtle",
"sys-locale",
"winit",
"winresource",
@@ -1712,7 +1721,7 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1722,7 +1731,7 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -1730,7 +1739,7 @@ dependencies = [
[[package]]
name = "filmcraft-apv"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1740,14 +1749,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",
@@ -1757,13 +1766,14 @@ dependencies = [
"schemars",
"serde",
"serde_json",
+ "tempfile",
"thiserror 2.0.21",
"tokio",
]
[[package]]
name = "filmcraft-av1"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1774,21 +1784,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",
+ "craft-media",
]
[[package]]
name = "filmcraft-captions"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@@ -1801,11 +1811,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 +1837,7 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
@@ -1865,7 +1875,7 @@ dependencies = [
[[package]]
name = "filmcraft-color"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -1873,7 +1883,7 @@ dependencies = [
[[package]]
name = "filmcraft-dng"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-dng",
"rayon",
@@ -1882,7 +1892,7 @@ dependencies = [
[[package]]
name = "filmcraft-dnx"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1892,7 +1902,7 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-geom",
"filmcraft-media",
@@ -1905,8 +1915,9 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
+ "chrono",
"craft-docpath",
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -1940,7 +1951,7 @@ dependencies = [
[[package]]
name = "filmcraft-export"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-aac",
"filmcraft-apv",
@@ -1971,7 +1982,7 @@ dependencies = [
[[package]]
name = "filmcraft-flac"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1980,20 +1991,22 @@ dependencies = [
[[package]]
name = "filmcraft-format"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
+ "chrono",
"filmcraft-media",
"filmcraft-project",
"filmcraft-time",
"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 +2016,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -2011,7 +2024,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -2028,7 +2041,7 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-export",
@@ -2047,7 +2060,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2057,7 +2070,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2067,7 +2080,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2077,7 +2090,7 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@@ -2094,26 +2107,26 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
- "filmcraft-bitstream",
+ "craft-media",
"filmcraft-testkit",
"proptest",
"serde_json",
- "thiserror 2.0.21",
]
[[package]]
name = "filmcraft-matroska"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
+ "craft-media",
"filmcraft-testkit",
"serde_json",
]
[[package]]
name = "filmcraft-media"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-dng",
@@ -2129,7 +2142,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpa"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2138,7 +2151,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpeg2v"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"rayon",
@@ -2147,7 +2160,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -2155,28 +2168,29 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
+ "chrono",
"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 +2216,7 @@ dependencies = [
[[package]]
name = "filmcraft-project"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2216,7 +2230,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2226,7 +2240,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -2246,7 +2260,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-color",
"serde",
@@ -2255,7 +2269,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2273,18 +2287,20 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
+ "chrono",
"png",
]
[[package]]
name = "filmcraft-text"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-color",
"harfrust",
"skrifa",
+ "tempfile",
"unicode-bidi",
"unicode-linebreak",
"unicode-segmentation",
@@ -2292,7 +2308,7 @@ dependencies = [
[[package]]
name = "filmcraft-time"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -2300,7 +2316,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2314,7 +2330,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts-text"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"serde",
"thiserror 2.0.21",
@@ -2322,7 +2338,7 @@ dependencies = [
[[package]]
name = "filmcraft-ui-egui"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"cpu-time",
"craft-ui",
@@ -2354,6 +2370,7 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
+ "tempfile",
"toml 1.1.6+spec-1.1.0",
"unicode-bidi",
"web-time",
@@ -2361,23 +2378,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 +7421,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.10.0"
+version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 46716a680..2801176d2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
@@ -67,10 +67,13 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
proptest = "1"
roxmltree = "0.21"
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
+craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", default-features = false }
# ui
# Shared frontend components; keep this dependency above the engine boundary.
@@ -90,11 +93,15 @@ too_many_arguments = "allow"
needless_range_loop = "allow"
type_complexity = "allow"
+# Line tables keep file and line in panics and backtraces at a fraction of full debug info's size;
+# dependencies need none.
[profile.dev.package."*"]
opt-level = 2
+debug = false
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
# Codec hot loops: debug-build tests (RFC/oracle vectors) are minutes slower below opt-level 3.
[profile.dev.package.filmcraft-h264]
diff --git a/NOTICE b/NOTICE
index 221a0acc6..d0d113091 100644
--- a/NOTICE
+++ b/NOTICE
@@ -17,5 +17,5 @@ Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fon
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
-BIZ UDMincho) — OFL-1.1. Their licence texts ship with the packages as OFL-.txt.
+BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian) — OFL-1.1. Their licence texts ship with the packages as OFL-.txt.
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md.
diff --git a/README.md b/README.md
index 176ea82e9..102e4a2a3 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
FilmCraft
diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs
index b57f1888b..cfc04fc3b 100644
--- a/apps/filmcraft-cli/src/main.rs
+++ b/apps/filmcraft-cli/src/main.rs
@@ -69,7 +69,9 @@ USAGE
SUBCOMMANDS
exec [key=value ...] run one command; prints its result as JSON
exec '' same, params as one JSON object
- run run commands, one {\"id\",\"params\"} per line (# comments ok)
+ run 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 one command as JSON (menu, shortcut, params, enabled now)
inspect [project|sequence] project tree or active sequence as JSON (default: both)
@@ -135,6 +137,25 @@ fn usage(msg: impl std::fmt::Display) -> ! {
std::process::exit(2)
}
+/// Read a file off the runtime thread: `cli` is async because MCP, bridge calls and control
+/// commands await, so a blocking `std::fs` read would stall every other task on that thread.
+async fn read_file(path: impl Into) -> std::io::Result> {
+ let path = path.into();
+ tokio::task::spawn_blocking(move || std::fs::read(path)).await.map_err(std::io::Error::other)?
+}
+
+/// `read_file` for UTF-8 text.
+async fn read_text(path: impl Into) -> std::io::Result {
+ let path = path.into();
+ tokio::task::spawn_blocking(move || std::fs::read_to_string(path)).await.map_err(std::io::Error::other)?
+}
+
+/// Write a file off the runtime thread; see `read_file`.
+async fn write_file(path: impl Into, data: Vec) -> std::io::Result<()> {
+ let path = path.into();
+ tokio::task::spawn_blocking(move || std::fs::write(path, data)).await.map_err(std::io::Error::other)?
+}
+
fn fail(msg: impl std::fmt::Display) -> ! {
diag(format_args!("filmcraft-cli: {msg}"));
std::process::exit(1)
@@ -169,9 +190,21 @@ impl Backend {
Backend::Local(Box::new(s))
}
- async fn exec(&mut self, id: &str, params: Value) -> Result {
+ async fn exec(&mut self, id: &str, mut params: Value) -> Result {
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()),
}
@@ -293,7 +326,7 @@ async fn cli() {
"bench-decode" => {
let path = a.pos(1).unwrap_or_else(|| usage("bench-decode "));
let n: i64 = a.opt("--frames").and_then(|v| v.parse().ok()).unwrap_or(120);
- let src = filmcraft_codecs::open_bytes(path, std::fs::read(path).unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e));
+ let src = filmcraft_codecs::open_bytes(path, read_file(path).await.unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e));
let rate = src.info().frame_rate();
let t0 = std::time::Instant::now();
for f in 0..n {
@@ -349,7 +382,7 @@ async fn cli() {
let text = if script == "-" {
std::io::read_to_string(std::io::stdin()).unwrap_or_else(|e| fail(e))
} else {
- std::fs::read_to_string(script).unwrap_or_else(|e| fail(format!("{script}: {e}")))
+ read_text(script).await.unwrap_or_else(|e| fail(format!("{script}: {e}")))
};
let mut b = Backend::open(&a);
let mut failed = 0;
@@ -473,7 +506,7 @@ async fn cli() {
let (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
let dt = t0.elapsed();
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
- std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
+ write_file(out, png).await.unwrap_or_else(|e| fail(format!("{out}: {e}")));
let offline: Vec = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect();
check_offline(&a, &offline, &format!("render: {out}"));
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/it/cli.rs
similarity index 94%
rename from apps/filmcraft-cli/tests/cli.rs
rename to apps/filmcraft-cli/tests/it/cli.rs
index d5ded27fa..57db7ae47 100644
--- a/apps/filmcraft-cli/tests/cli.rs
+++ b/apps/filmcraft-cli/tests/it/cli.rs
@@ -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]
diff --git a/apps/filmcraft-cli/tests/it/main.rs b/apps/filmcraft-cli/tests/it/main.rs
new file mode 100644
index 000000000..9bdd14068
--- /dev/null
+++ b/apps/filmcraft-cli/tests/it/main.rs
@@ -0,0 +1,2 @@
+mod cli;
+mod workflows;
diff --git a/apps/filmcraft-cli/tests/workflows.rs b/apps/filmcraft-cli/tests/it/workflows.rs
similarity index 100%
rename from apps/filmcraft-cli/tests/workflows.rs
rename to apps/filmcraft-cli/tests/it/workflows.rs
diff --git a/apps/filmcraft-web/src/download_pacing.rs b/apps/filmcraft-web/src/download_pacing.rs
index 01f9f4212..944e74bd3 100644
--- a/apps/filmcraft-web/src/download_pacing.rs
+++ b/apps/filmcraft-web/src/download_pacing.rs
@@ -1,4 +1,4 @@
-//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the
+//! Spacing of browser downloads (pure, so it is tested natively in `tests/it/download_pacing.rs`; the
//! anchor clicks live in [`crate::fs::download`]).
//!
//! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export
diff --git a/apps/filmcraft-web/src/fs.rs b/apps/filmcraft-web/src/fs.rs
index 416fbf9d7..d7ec9072f 100644
--- a/apps/filmcraft-web/src/fs.rs
+++ b/apps/filmcraft-web/src/fs.rs
@@ -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 {
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);
+ });
+ }
+}
diff --git a/apps/filmcraft-web/src/recovery_policy.rs b/apps/filmcraft-web/src/recovery_policy.rs
index 8426d3c39..7a2e0fc26 100644
--- a/apps/filmcraft-web/src/recovery_policy.rs
+++ b/apps/filmcraft-web/src/recovery_policy.rs
@@ -1,5 +1,5 @@
//! When the web recovery snapshot is written or marked clean (pure, so it is tested natively in
-//! `tests/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
+//! `tests/it/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
//!
//! - A dirty project is snapshotted at most every [`INTERVAL_S`] seconds, once per revision. A write
//! that fails is retried after the interval, without waiting for another edit (#197).
diff --git a/apps/filmcraft-web/tests/download_pacing.rs b/apps/filmcraft-web/tests/it/download_pacing.rs
similarity index 87%
rename from apps/filmcraft-web/tests/download_pacing.rs
rename to apps/filmcraft-web/tests/it/download_pacing.rs
index 893f40d3f..4f2ee8d26 100644
--- a/apps/filmcraft-web/tests/download_pacing.rs
+++ b/apps/filmcraft-web/tests/it/download_pacing.rs
@@ -1,10 +1,10 @@
//! Download spacing. It is pure, so it runs natively even though the rest of the crate is
//! wasm32-only.
-#[path = "../src/download_pacing.rs"]
-mod download_pacing;
+#[path = "../../src/download_pacing.rs"]
+mod pacing;
-use download_pacing::{GAP_MS, Pacer};
+use pacing::{GAP_MS, Pacer};
/// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst.
#[test]
diff --git a/apps/filmcraft-web/tests/it/main.rs b/apps/filmcraft-web/tests/it/main.rs
new file mode 100644
index 000000000..a223d161d
--- /dev/null
+++ b/apps/filmcraft-web/tests/it/main.rs
@@ -0,0 +1,2 @@
+mod download_pacing;
+mod recovery_policy;
diff --git a/apps/filmcraft-web/tests/recovery_policy.rs b/apps/filmcraft-web/tests/it/recovery_policy.rs
similarity index 96%
rename from apps/filmcraft-web/tests/recovery_policy.rs
rename to apps/filmcraft-web/tests/it/recovery_policy.rs
index c60b34d5b..fe64599d7 100644
--- a/apps/filmcraft-web/tests/recovery_policy.rs
+++ b/apps/filmcraft-web/tests/it/recovery_policy.rs
@@ -1,10 +1,10 @@
//! The web recovery snapshot policy. It is pure, so it runs natively even though the rest of the
//! crate is wasm32-only.
-#[path = "../src/recovery_policy.rs"]
-mod recovery_policy;
+#[path = "../../src/recovery_policy.rs"]
+mod policy;
-use recovery_policy::{Action, INTERVAL_S, Policy};
+use policy::{Action, INTERVAL_S, Policy};
/// #197: a snapshot write that fails is retried after the interval without another edit.
#[test]
diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml
index 3ae35e6c6..e0730dc37 100644
--- a/apps/filmcraft/Cargo.toml
+++ b/apps/filmcraft/Cargo.toml
@@ -29,12 +29,14 @@ 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"
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
arboard = "3"
-chrono = { version = "0.4", default-features = false, features = ["clock"] }
+chrono = { workspace = true, features = ["clock"] }
sys-locale = "0.3"
# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft).
open = "5"
diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs
index c64e3509f..130e23ee2 100644
--- a/apps/filmcraft/src/args.rs
+++ b/apps/filmcraft/src/args.rs
@@ -9,7 +9,8 @@ Usage: filmcraft [options] [project.fcproj | media files...]
Options:
--control 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
diff --git a/apps/filmcraft/src/control_server.rs b/apps/filmcraft/src/control_server.rs
index d3b40f320..af1876f7b 100644
--- a/apps/filmcraft/src/control_server.rs
+++ b/apps/filmcraft/src/control_server.rs
@@ -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 {
+ start_with_token(port, ctx, std::env::var("FILMCRAFT_CONTROL_TOKEN").ok())
+}
+
+fn start_with_token(port: u16, ctx: egui::Context, token: Option) -> Receiver {
let (tx, rx) = channel::();
+ // 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 {
}
};
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, ctx: egui::Context) {
+fn accept_loop(listener: TcpListener, tx: Sender, 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, 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, ctx: &egui::Context) {
+fn serve(stream: TcpStream, tx: &Sender, 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, 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(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option) {
+fn serve_lines(read: R, mut out: W, token: &str, mut handle: impl FnMut(String, Value) -> Option) {
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(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
+ const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
fn run(input: &[u8]) -> (Vec, Vec) {
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::();
- 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 = (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::();
+ 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::();
+ 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::(&reply).unwrap(), json!({"id": 7, "ok": true}));
+ }
}
diff --git a/apps/filmcraft/src/dev_icon.rs b/apps/filmcraft/src/dev_icon.rs
index 8c9876794..b14af1505 100644
--- a/apps/filmcraft/src/dev_icon.rs
+++ b/apps/filmcraft/src/dev_icon.rs
@@ -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
}
diff --git a/apps/filmcraft/src/logging.rs b/apps/filmcraft/src/logging.rs
index 3d7c713fb..006b7f884 100644
--- a/apps/filmcraft/src/logging.rs
+++ b/apps/filmcraft/src/logging.rs
@@ -97,29 +97,35 @@ fn matches(name: &str, target: &str) -> bool {
}
}
-/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
+/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
+/// instead of panicking.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
- let secs = since.as_secs();
- let (days, rem) = (secs / 86_400, secs % 86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
+ let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
-/// clock can't overflow).
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days.saturating_add(719_468);
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
- (y, m, d)
+/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
+/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
+/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
+/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
+/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
+fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -390,6 +396,17 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
+ // collapsing to the epoch (reviewer FredApps). Windows' SystemTime can't hold such an
+ // instant, so it is built with a checked add and only asserted where it exists.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs
index f850b1638..5ffec5aa2 100644
--- a/apps/filmcraft/src/main.rs
+++ b/apps/filmcraft/src/main.rs
@@ -8,6 +8,7 @@
//! (the demo project), Open Most Recent, or an empty project.
//!
//! `--control ` (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.
//!
diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs
index 3e79337e0..20cc48aad 100644
--- a/apps/filmcraft/src/native_menu.rs
+++ b/apps/filmcraft/src/native_menu.rs
@@ -19,6 +19,15 @@ fn accel(s: &str) -> Option {
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) {
+ 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;
@@ -53,9 +62,12 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, 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, Men
hide,
hide_others,
&PredefinedMenuItem::separator(),
- quit,
+ &quit,
]);
let _ = bar.append(&app_menu);
for top in MENUS {
@@ -149,6 +161,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, 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 {
@@ -166,3 +179,20 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, 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");
+ }
+}
diff --git a/crates/aac/tests/common/mod.rs b/crates/aac/tests/it/common/mod.rs
similarity index 100%
rename from crates/aac/tests/common/mod.rs
rename to crates/aac/tests/it/common/mod.rs
diff --git a/crates/aac/tests/decoder_oracle.rs b/crates/aac/tests/it/decoder_oracle.rs
similarity index 99%
rename from crates/aac/tests/decoder_oracle.rs
rename to crates/aac/tests/it/decoder_oracle.rs
index 721b94486..b5591a56a 100644
--- a/crates/aac/tests/decoder_oracle.rs
+++ b/crates/aac/tests/it/decoder_oracle.rs
@@ -1,7 +1,7 @@
//! Decoder accuracy against ffmpeg's decoder on streams produced by ffmpeg's AAC encoder (oracle
//! only; skipped when ffmpeg is absent).
-mod common;
+use crate::common;
use common::*;
diff --git a/crates/aac/tests/encoder_oracle.rs b/crates/aac/tests/it/encoder_oracle.rs
similarity index 99%
rename from crates/aac/tests/encoder_oracle.rs
rename to crates/aac/tests/it/encoder_oracle.rs
index 3cc312ace..d73abdbb8 100644
--- a/crates/aac/tests/encoder_oracle.rs
+++ b/crates/aac/tests/it/encoder_oracle.rs
@@ -1,6 +1,6 @@
//! Encoder quality/bitrate tests using ffmpeg as an external decoding oracle (skipped if absent).
-mod common;
+use crate::common;
use common::*;
use filmcraft_aac::{BitrateMode, EncoderConfig, WindowShape};
diff --git a/crates/aac/tests/it/main.rs b/crates/aac/tests/it/main.rs
new file mode 100644
index 000000000..6f4d1e3bd
--- /dev/null
+++ b/crates/aac/tests/it/main.rs
@@ -0,0 +1,4 @@
+mod common;
+mod decoder_oracle;
+mod encoder_oracle;
+mod robustness;
diff --git a/crates/aac/tests/robustness.rs b/crates/aac/tests/it/robustness.rs
similarity index 99%
rename from crates/aac/tests/robustness.rs
rename to crates/aac/tests/it/robustness.rs
index 9c1bb9ea0..fd0e83ee6 100644
--- a/crates/aac/tests/robustness.rs
+++ b/crates/aac/tests/it/robustness.rs
@@ -1,7 +1,7 @@
//! Fuzz-style robustness: mutated access units, configs and ADTS streams must never panic, and the
//! encoder must accept hostile input (NaN, ±inf, > full scale, silence, odd chunk sizes).
-mod common;
+use crate::common;
use common::*;
use filmcraft_aac::{AudioSpecificConfig, Decoder, Encoder, EncoderConfig, split_adts};
diff --git a/crates/apv/README.md b/crates/apv/README.md
index e84c9f5f8..906e4b4ae 100644
--- a/crates/apv/README.md
+++ b/crates/apv/README.md
@@ -54,7 +54,7 @@ let apvc: Vec = enc.decoder_config_record(); // APVDecoderConfigurationReco
- **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume)
and `metadata_cll` (`payloadType == 6`, Content Light-Level Information).
- **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient
- ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/fuzz.rs`
+ ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/it/fuzz.rs`
across thousands of mutated and random inputs.
### Encoder
@@ -67,7 +67,7 @@ let apvc: Vec = enc.decoder_config_record(); // APVDecoderConfigurationReco
## Accuracy (vs FFmpeg as an External Oracle)
-`tests/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
+`tests/it/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting
`.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `filmcraft_apv::decode_frame`.
Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact
diff --git a/crates/apv/src/decode.rs b/crates/apv/src/decode.rs
index 997aaa0b4..910ea5207 100644
--- a/crates/apv/src/decode.rs
+++ b/crates/apv/src/decode.rs
@@ -377,7 +377,20 @@ fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[
let blocks_x = blk_w / 8;
let blocks_y = blk_h / 8;
- let mut comp_buf = vec![0u16; tw_c * th_c];
+ // Every coded 8x8 block needs at least one bit for its DC code. Validate
+ // that the component can describe every required block before allocating
+ // its complete reconstructed plane.
+ let block_count = (num_mb_cols as usize)
+ .checked_mul(num_mb_rows as usize)
+ .and_then(|n| n.checked_mul(blocks_x))
+ .and_then(|n| n.checked_mul(blocks_y))
+ .ok_or(Error::CorruptTile(tile_idx))?;
+ if comp_bytes.len() < block_count.div_ceil(8) {
+ return Err(Error::CorruptTile(tile_idx));
+ }
+ let samples = tw_c.checked_mul(th_c).ok_or(Error::CorruptTile(tile_idx))?;
+
+ let mut comp_buf = vec![0u16; samples];
let mut r = BitReader::new(comp_bytes);
let mut prev_dc: i32 = 0;
diff --git a/crates/apv/tests/fuzz.rs b/crates/apv/tests/it/fuzz.rs
similarity index 100%
rename from crates/apv/tests/fuzz.rs
rename to crates/apv/tests/it/fuzz.rs
diff --git a/crates/apv/tests/it/fuzz_hostile.rs b/crates/apv/tests/it/fuzz_hostile.rs
new file mode 100644
index 000000000..bb02fdd07
--- /dev/null
+++ b/crates/apv/tests/it/fuzz_hostile.rs
@@ -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> {
+ 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}"));
+ }
+ }
+}
diff --git a/crates/apv/tests/it/main.rs b/crates/apv/tests/it/main.rs
new file mode 100644
index 000000000..51c79839a
--- /dev/null
+++ b/crates/apv/tests/it/main.rs
@@ -0,0 +1,3 @@
+mod fuzz;
+mod fuzz_hostile;
+mod oracle;
diff --git a/crates/apv/tests/oracle.rs b/crates/apv/tests/it/oracle.rs
similarity index 100%
rename from crates/apv/tests/oracle.rs
rename to crates/apv/tests/it/oracle.rs
diff --git a/crates/audio-dsp/src/effects/mod.rs b/crates/audio-dsp/src/effects/mod.rs
index 1a6f02a33..9a6f781cc 100644
--- a/crates/audio-dsp/src/effects/mod.rs
+++ b/crates/audio-dsp/src/effects/mod.rs
@@ -271,31 +271,37 @@ mod tests {
#[test]
fn extreme_settings_stay_finite_and_denormal_free() {
- for e in effects() {
- for setting in [0.0f32, 1.0] {
- let mut fx = (e.create)(SR, 2);
- for p in e.params {
- fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max });
- }
- let mut l = noise(3, 24000, 4.0);
- let mut r = noise(4, 24000, 4.0);
- l[100] = 1e6;
- r[200] = -1e6;
- fx.process(&mut [&mut l, &mut r]);
- assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id);
- // Long silence afterwards: output decays without denormals.
- fx.reset();
- let mut l = noise(3, 4800, 0.5);
- let mut r = noise(4, 4800, 0.5);
- fx.process(&mut [&mut l, &mut r]);
- for _ in 0..40 {
- let mut l = vec![0.0f32; 48000];
- let mut r = vec![0.0f32; 48000];
- fx.process(&mut [&mut l, &mut r]);
- assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id);
- }
+ // One thread per effect. The tail stays 40 s long: delays, reverbs and graphic EQs still
+ // produce output in its last second, some of it within 3x of the smallest normal f32.
+ std::thread::scope(|scope| {
+ for e in effects() {
+ scope.spawn(move || {
+ for setting in [0.0f32, 1.0] {
+ let mut fx = (e.create)(SR, 2);
+ for p in e.params {
+ fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max });
+ }
+ let mut l = noise(3, 24000, 4.0);
+ let mut r = noise(4, 24000, 4.0);
+ l[100] = 1e6;
+ r[200] = -1e6;
+ fx.process(&mut [&mut l, &mut r]);
+ assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id);
+ // Long silence afterwards: output decays without denormals.
+ fx.reset();
+ let mut l = noise(3, 4800, 0.5);
+ let mut r = noise(4, 4800, 0.5);
+ fx.process(&mut [&mut l, &mut r]);
+ for _ in 0..40 {
+ let mut l = vec![0.0f32; 48000];
+ let mut r = vec![0.0f32; 48000];
+ fx.process(&mut [&mut l, &mut r]);
+ assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id);
+ }
+ }
+ });
}
- }
+ });
}
#[test]
diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml
index 14b090e7d..9bd3e8766 100644
--- a/crates/automation/Cargo.toml
+++ b/crates/automation/Cargo.toml
@@ -16,7 +16,8 @@ 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"] }
+tempfile = "3"
diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs
index f51db9adf..032e4e657 100644
--- a/crates/automation/src/bridge.rs
+++ b/crates/automation/src/bridge.rs
@@ -20,6 +20,8 @@ type Conn = (BufReader, tokio::net::tcp::OwnedWr
pub struct BridgeClient {
addr: String,
+ /// Inherited from the same owner-controlled environment as the desktop app.
+ token: Option,
/// 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) -> Result {
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 = 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");
+ }
}
diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs
index fad4e4192..b2e313504 100644
--- a/crates/automation/src/server.rs
+++ b/crates/automation/src/server.rs
@@ -564,15 +564,21 @@ impl FilmcraftMcp {
impl FilmcraftMcp {
async fn screenshot(&self, b: &BridgeClient, panel: Option, max: u32) -> Result {
- let path = std::env::temp_dir().join(format!("filmcraft-mcp-{}.png", std::process::id()));
+ // The desktop bridge needs a pathname, so use a freshly-created,
+ // owner-only directory rather than exposing image bytes at a
+ // predictable shared-temp filename.
+ let dir = match tempfile::tempdir() {
+ Ok(dir) => dir,
+ Err(e) => return Ok(fail(format!("screenshot: {e}"))),
+ };
+ let path = dir.path().join("screenshot.png");
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 bytes = match image::load_from_memory(&bytes) {
Ok(img) => {
let rgba = img.to_rgba8();
diff --git a/crates/av1/README.md b/crates/av1/README.md
index 354484158..8d29c1eeb 100644
--- a/crates/av1/README.md
+++ b/crates/av1/README.md
@@ -43,12 +43,12 @@ reference) is used only as an external test oracle.
## Accuracy
-`tests/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the
+`tests/it/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the
in-loop filters; 64×64 and 128×128 superblocks; intra edge filter on and off) and compares every
decoded frame with libdav1d: **bit-exact** on all 13 fixtures (8- and 10-bit 4:2:0; 128×128 to
1280×720 including odd sizes; testsrc2, mandelbrot, gradients and noise; presets 1–8).
-`tests/conformance.rs` downloads libaom's conformance test vectors on first use
+`tests/it/conformance.rs` downloads libaom's conformance test vectors on first use
(storage.googleapis.com/aom-test-data) and compares with libdav1d: `av1-1-b8-02-allintra`
(39 frames, deblocking + CDEF + self-guided restoration), `05-mv`, `06-mfmv`, `24-monochrome`
and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored
@@ -58,8 +58,8 @@ and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored
with scaled inter-layer prediction), `24-monochrome` (8 and 10-bit) and
`16-intra_only-intrabc-extreme-dv` (1080p intra block copy), `23-film_grain-50` (8 and 10-bit).
-`tests/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with
-libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
+`tests/it/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with
+libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/it/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
OBMC / warped motion, motion-field projection, all loop filters; presets 3–8, 8- and 10-bit, odd
sizes), plus super-resolution (denominator 12) and film-grain streams, and compares every frame
with libdav1d: **bit-exact** on all 6 fixtures.
@@ -76,13 +76,13 @@ independent). Results never depend on scheduling (the oracle tests compare 1 thr
and libdav1d). With frame threads, pictures may be returned by a
later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it.
-`tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame
+`tests/it/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame
(`Decoder::stats()`) and all-core fps with its per-stage ms/frame for 1080p fixtures (key frames,
GOPs, tiles, low delay) and a single-tile 2160p GOP; `AV1_PERF_ONLY=a,b` picks fixtures. The hot loops (sub-pixel filters,
CDEF, inverse transforms, deblocking, entropy decoding) are written for auto-vectorisation.
Measured 2026-10-01 on a 14-core Apple Silicon Mac (release build, SVT-AV1 preset 8 1080p
-fixtures from `tests/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame /
+fixtures from `tests/it/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame /
post-filter threading. The machine was heavily loaded by other builds during both runs (load
average 200–400), so the all-core wall figures understate what an idle machine reaches and are
noisy; single-threaded figures use thread CPU time and are comparable.
@@ -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
diff --git a/crates/av1/src/decoder.rs b/crates/av1/src/decoder.rs
index 66c966986..c9f487d95 100644
--- a/crates/av1/src/decoder.rs
+++ b/crates/av1/src/decoder.rs
@@ -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;
@@ -92,6 +103,8 @@ struct FrameJob {
ref_info: [RefInfo; NUM_REF_FRAMES],
/// Tile payloads (tile number, data).
tiles: Vec<(usize, Vec)>,
+ /// Each tile may be supplied by exactly one tile group.
+ tile_covered: Vec,
slot: Arc,
/// The output picture, when the frame is shown.
picture: Option>>>,
@@ -505,7 +518,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 {
@@ -520,12 +533,14 @@ impl Decoder {
}
let picture = fh.show_frame.then(Slot::new);
let draft = self.draft && fh.show_frame && fh.refresh_frame_flags == 0;
+ let tile_count = fh.tile_info.cols.checked_mul(fh.tile_info.rows).ok_or(Error::Invalid("tile count overflows"))?;
self.pending = Some(Box::new(FrameJob {
seq,
fh,
refs: self.ref_frames.clone(),
ref_info: self.refs.clone(),
tiles: Vec::new(),
+ tile_covered: vec![false; tile_count],
slot: Slot::new(),
picture,
pts: self.pts,
@@ -568,13 +583,20 @@ impl Decoder {
v + 1
};
let end = pos.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
+ if job.tile_covered.get(tile_num).copied() != Some(false) {
+ return Err(Error::Invalid("tile is covered by multiple tile groups"));
+ }
job.tiles.push((tile_num, data[pos..end].to_vec()));
+ job.tile_covered[tile_num] = true;
pos = end;
}
if tg_end != num_tiles - 1 {
self.pending = Some(job);
return Ok(None);
}
+ if job.tile_covered.iter().any(|&covered| !covered) {
+ return Err(Error::Invalid("frame is missing tile coverage"));
+ }
self.seen_frame_header = false;
Ok(self.submit(job))
}
@@ -947,3 +969,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));
+ }
+}
diff --git a/crates/av1/src/tile.rs b/crates/av1/src/tile.rs
index 9d5344652..6776f201f 100644
--- a/crates/av1/src/tile.rs
+++ b/crates/av1/src/tile.rs
@@ -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;
diff --git a/crates/av1/tests/fuzz_hostile.rs b/crates/av1/tests/fuzz_hostile.rs
new file mode 100644
index 000000000..4421b18c4
--- /dev/null
+++ b/crates/av1/tests/fuzz_hostile.rs
@@ -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> {
+ 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>> {
+ 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], 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}"));
+ }
+ }
+}
diff --git a/crates/av1/tests/adhoc.rs b/crates/av1/tests/it/adhoc.rs
similarity index 92%
rename from crates/av1/tests/adhoc.rs
rename to crates/av1/tests/it/adhoc.rs
index e140b9768..1595bc257 100644
--- a/crates/av1/tests/adhoc.rs
+++ b/crates/av1/tests/it/adhoc.rs
@@ -1,7 +1,7 @@
//! Compare an arbitrary IVF file with libdav1d:
-//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test adhoc -- --ignored --nocapture`
+//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test it adhoc:: -- --ignored --nocapture`
-mod common;
+use crate::common;
use common::*;
#[test]
diff --git a/crates/av1/tests/common/mod.rs b/crates/av1/tests/it/common/mod.rs
similarity index 100%
rename from crates/av1/tests/common/mod.rs
rename to crates/av1/tests/it/common/mod.rs
diff --git a/crates/av1/tests/conformance.rs b/crates/av1/tests/it/conformance.rs
similarity index 94%
rename from crates/av1/tests/conformance.rs
rename to crates/av1/tests/it/conformance.rs
index 033f590e2..b70270af0 100644
--- a/crates/av1/tests/conformance.rs
+++ b/crates/av1/tests/it/conformance.rs
@@ -1,6 +1,6 @@
//! libaom conformance test vectors (downloaded on first use), compared with libdav1d.
-mod common;
+use crate::common;
use common::*;
/// Vectors the decoder handles today (more are added as stages land).
@@ -17,7 +17,7 @@ fn conformance_vectors() {
}
/// The wider libaom set (slow: many downloads and decodes):
-/// `cargo test -p filmcraft-av1 --test conformance -- --ignored`; `AV1_VECTORS=a,b` overrides it.
+/// `cargo test -p filmcraft-av1 --test it conformance:: -- --ignored`; `AV1_VECTORS=a,b` overrides it.
#[test]
#[ignore]
fn conformance_vectors_extended() {
diff --git a/crates/av1/tests/it/hostile_dims.rs b/crates/av1/tests/it/hostile_dims.rs
new file mode 100644
index 000000000..1312e938d
--- /dev/null
+++ b/crates/av1/tests/it/hostile_dims.rs
@@ -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,
+ 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 {
+ 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 {
+ 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()));
+}
diff --git a/crates/av1/tests/it/main.rs b/crates/av1/tests/it/main.rs
new file mode 100644
index 000000000..decf05989
--- /dev/null
+++ b/crates/av1/tests/it/main.rs
@@ -0,0 +1,7 @@
+mod adhoc;
+mod common;
+mod conformance;
+mod hostile_dims;
+mod oracle_inter;
+mod oracle_intra;
+mod perf;
diff --git a/crates/av1/tests/oracle_inter.rs b/crates/av1/tests/it/oracle_inter.rs
similarity index 99%
rename from crates/av1/tests/oracle_inter.rs
rename to crates/av1/tests/it/oracle_inter.rs
index 06a4d88fe..5d443194f 100644
--- a/crates/av1/tests/oracle_inter.rs
+++ b/crates/av1/tests/it/oracle_inter.rs
@@ -2,7 +2,7 @@
//! GOPs with hierarchical references, compound prediction, OBMC / warped motion, global motion,
//! motion-field projection and all loop filters.
-mod common;
+use crate::common;
use common::*;
fn specs() -> Vec {
diff --git a/crates/av1/tests/oracle_intra.rs b/crates/av1/tests/it/oracle_intra.rs
similarity index 99%
rename from crates/av1/tests/oracle_intra.rs
rename to crates/av1/tests/it/oracle_intra.rs
index 441220f18..d002af67e 100644
--- a/crates/av1/tests/oracle_intra.rs
+++ b/crates/av1/tests/it/oracle_intra.rs
@@ -1,6 +1,6 @@
//! Intra-frame bit-exactness against libdav1d (run as an external ffmpeg process).
-mod common;
+use crate::common;
use common::*;
const NO_FILTERS: &str = "enable-cdef=0:enable-restoration=0:enable-dlf=0:keyint=1";
diff --git a/crates/av1/tests/perf.rs b/crates/av1/tests/it/perf.rs
similarity index 97%
rename from crates/av1/tests/perf.rs
rename to crates/av1/tests/it/perf.rs
index 9047605aa..518a0cac3 100644
--- a/crates/av1/tests/perf.rs
+++ b/crates/av1/tests/it/perf.rs
@@ -1,11 +1,11 @@
//! Decode throughput and per-stage breakdown (ignored; run with
-//! `cargo test --release -p filmcraft-av1 --test perf -- --ignored --nocapture`).
+//! `cargo test --release -p filmcraft-av1 --test it perf:: -- --ignored --nocapture`).
//!
//! The machine may be busy (other builds), so single-threaded work is also measured with the
//! thread CPU clock, which is robust to load; wall-clock figures are printed next to the load
//! average.
-mod common;
+use crate::common;
use common::*;
use filmcraft_av1::{DecodeStats, Decoder, Stage};
use std::time::Instant;
diff --git a/crates/av1/tools/extract_tables.py b/crates/av1/tools/extract_tables.py
index 83c4e2c9f..a037a3765 100644
--- a/crates/av1/tools/extract_tables.py
+++ b/crates/av1/tools/extract_tables.py
@@ -17,8 +17,22 @@ 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
-root = sys.argv[1]
+
+def _spec_dir(arg):
+ """Resolve the CLI argument and require it to be an existing directory."""
+ path = Path(arg).resolve()
+ if not path.is_dir():
+ sys.exit(f"not a directory: {arg}")
+ return path
+
+
+if len(sys.argv) != 2:
+ sys.exit("usage: extract_tables.py ")
+# The argument is the operator's own spec checkout; _spec_dir requires it to be an existing
+# directory before anything is read from it.
+root = _spec_dir(sys.argv[1]) # NOSONAR
files = sorted(f for f in os.listdir(root) if f.endswith(".md"))
texts = {f: open(os.path.join(root, f), encoding="utf-8").read() for f in files}
@@ -35,7 +49,7 @@ def ev(expr):
# --- constants: symbol table (section 3) ---
pending = []
for line in texts["03.symbols.md"].split("\n"):
- m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^|]+?)\s*\|", line)
+ m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^\s|](?:[^|]*[^|\s])?)\s*\|", line)
if m:
pending.append((m.group(1), m.group(2)))
# --- constants: value/name tables (section 6 semantics, section 8/9) ---
@@ -77,7 +91,7 @@ for name, val in pending:
def cells(text):
"""Split one initialiser cell. A missing comma between two operands on separate lines
(Split_Tx_Size in the spec text: `TX_32X32` / `TX_4X8`) yields two cells."""
- parts = re.split(r"(?<=[A-Za-z0-9_])\s*\n\s*(?=[A-Za-z0-9_])", text.strip())
+ parts = re.split(r"(?<=[A-Za-z0-9_])[^\S\n]*\n[^\S\n]*(?=[A-Za-z0-9_])", text.strip())
if len(parts) > 1:
sys.stderr.write("note: missing comma in spec table between %s\n" % parts)
return parts
@@ -118,10 +132,25 @@ def shape(x):
return []
+# `~~~~` opens a block on its own line (with an optional `c` marker) and the next run of
+# tildes closes it. Walk the pairs with two linear searches instead of the lazy `(.*?)`
+# with re.S, which rescans the whole remaining text at every position (super-linear).
+FENCE_OPEN = re.compile(r"~~~~~?\s*c?\n")
+FENCE_CLOSE = re.compile(r"~~~~~?")
+
tables = {}
for f in files:
t = texts[f]
- for block in re.findall(r"~~~~~?\s*c?\n(.*?)~~~~~?", t, re.S):
+ pos = 0
+ while True:
+ m = FENCE_OPEN.search(t, pos)
+ if not m:
+ break
+ c = FENCE_CLOSE.search(t, m.end())
+ if not c:
+ break
+ block = t[m.end():c.start()]
+ pos = c.end()
b = re.sub(r"//[^\n]*", "", block)
b = re.sub(r"/\*.*?\*/", "", b, flags=re.S)
for m in re.finditer(r"(?m)^\s*([A-Z][A-Za-z0-9_]*)\s*((?:\[[^\]=]*\]\s*)+)=\s*", b):
diff --git a/crates/avi/src/lib.rs b/crates/avi/src/lib.rs
index 0294fdfea..e11bc7b5f 100644
--- a/crates/avi/src/lib.rs
+++ b/crates/avi/src/lib.rs
@@ -21,6 +21,8 @@ mod tests;
pub use source::ByteSource;
+use std::collections::HashSet;
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// Not an AVI file (no `RIFF` … `AVI ` header).
@@ -175,6 +177,9 @@ pub struct OpenOptions {
/// Headers larger than this are refused (a real `hdrl` is a few kilobytes).
const MAX_HEADER: u64 = 16 << 20;
+/// Total OpenDML standard-index bytes accepted while opening one file. This bounds hostile
+/// repeated index references without constraining practical large-file indexes.
+const MAX_ODML_INDEX_BYTES: u64 = 64 << 20;
/// Streams beyond this are ignored.
const MAX_STREAMS: usize = 64;
/// `LIST rec ` inside `movi` nests once; deeper is refused.
@@ -443,34 +448,52 @@ fn super_index(b: &[u8]) -> Vec {
/// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream
/// that has data lacks one or none could be read.
fn read_odml(src: &S, streams: &mut [Stream]) -> bool {
- let limit = src.len() / 8;
- let mut total = 0u64;
+ let entry_limit = src.len() / 8;
+ let mut entries = 0u64;
+ let mut index_bytes = 0u64;
+ let mut seen = HashSet::new();
for s in streams.iter_mut() {
s.chunks.clear();
s.keyframes_known = true;
for &at in &s.super_index {
+ // A super index can repeat an offset (even across streams). Read and process a
+ // standard index once, rather than multiplying its work and allocations.
+ if !seen.insert(at) {
+ continue;
+ }
let Ok((id, size, data)) = header_at(src, at) else { continue };
if &id[..2] != b"ix" {
continue;
}
- let Ok(b) = read(src, data, size.min(MAX_HEADER * 8)) else { continue };
- let (longs, subtype, itype) = (usize::from(le16(&b, 0)), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0));
+ // Validate the fixed header before allocating the variable entry body.
+ if size < 24 || index_bytes.checked_add(24).is_none_or(|n| n > MAX_ODML_INDEX_BYTES) {
+ return false;
+ }
+ let Ok(header) = read(src, data, 24) else { continue };
+ index_bytes += 24;
+ let (longs, subtype, itype) = (usize::from(le16(&header, 0)), header.get(2).copied().unwrap_or(0), header.get(3).copied().unwrap_or(0));
// AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD)
if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) {
continue;
}
- let base = le64(&b, 12);
let step = longs * 4;
- let n = (le32(&b, 4) as usize).min(b.len().saturating_sub(24) / step);
+ let n = u64::from(le32(&header, 4)).min(size.saturating_sub(24) / step as u64);
+ let body = n.saturating_mul(step as u64);
+ let Some(next_bytes) = index_bytes.checked_add(body) else { return false };
+ let Some(next_entries) = entries.checked_add(n) else { return false };
+ if next_bytes > MAX_ODML_INDEX_BYTES || next_entries > entry_limit {
+ return false;
+ }
+ let Ok(b) = read(src, data, 24 + body) else { continue };
+ index_bytes = next_bytes;
+ entries = next_entries;
+ let base = le64(&b, 12);
+ let n = usize::try_from(n).unwrap_or(0);
for k in 0..n {
let e = 24 + k * step;
let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4));
let size = raw & 0x7FFF_FFFF;
let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { continue };
- total += 1;
- if total > limit {
- return false;
- }
s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 });
}
}
diff --git a/crates/avi/src/tests.rs b/crates/avi/src/tests.rs
index 248364d6f..42e3dce9f 100644
--- a/crates/avi/src/tests.rs
+++ b/crates/avi/src/tests.rs
@@ -267,3 +267,18 @@ fn opendml_standard_indexes() {
}
}
}
+
+#[test]
+fn opendml_repeated_index_offsets_are_read_once() {
+ let (_, at) = avi_odml([0, 0]);
+ let (file, _) = avi_odml(at);
+ let mut streams = open(&file).unwrap().streams;
+ // A malicious super index repeats each valid standard-index address. It must not multiply
+ // its chunks, allocations, or traversal work.
+ for (stream, &index) in streams.iter_mut().zip(&at) {
+ stream.super_index = vec![index; 100];
+ }
+ assert!(read_odml(&file, &mut streams));
+ assert_eq!(streams[0].chunks.len(), 3);
+ assert_eq!(streams[1].chunks.len(), 2);
+}
diff --git a/crates/bitstream/Cargo.toml b/crates/bitstream/Cargo.toml
index 8510e348e..07e798ae4 100644
--- a/crates/bitstream/Cargo.toml
+++ b/crates/bitstream/Cargo.toml
@@ -9,6 +9,4 @@ rust-version.workspace = true
workspace = true
[dependencies]
-
-[dev-dependencies]
-proptest = { workspace = true }
+craft-media = { workspace = true, features = ["bitstream"] }
diff --git a/crates/bitstream/src/lib.rs b/crates/bitstream/src/lib.rs
index 1bdb51265..812b0d68c 100644
--- a/crates/bitstream/src/lib.rs
+++ b/crates/bitstream/src/lib.rs
@@ -1,478 +1,5 @@
-//! Bit-level reading and writing shared by FilmCraft's codec crates.
-//!
-//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
-//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
-//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
-//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
+//! Bit-level reading and writing: a re-export of [`craft_media::bitstream`] from craft-libs.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
-use std::fmt;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum BitError {
- /// Tried to read past the end of the buffer.
- Eof,
- /// An Exp-Golomb code longer than 32 leading zeros.
- InvalidExpGolomb,
-}
-
-impl fmt::Display for BitError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- BitError::Eof => f.write_str("unexpected end of bitstream"),
- BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
- }
- }
-}
-impl std::error::Error for BitError {}
-
-pub type Result = std::result::Result;
-
-/// MSB-first bit reader over a byte slice.
-#[derive(Clone)]
-pub struct BitReader<'a> {
- data: &'a [u8],
- /// Position in bits.
- pos: usize,
-}
-
-impl<'a> BitReader<'a> {
- pub fn new(data: &'a [u8]) -> Self {
- Self { data, pos: 0 }
- }
- pub fn data(&self) -> &'a [u8] {
- self.data
- }
- /// Bit position from the start.
- pub fn position(&self) -> usize {
- self.pos
- }
- pub fn bits_left(&self) -> usize {
- (self.data.len() * 8).saturating_sub(self.pos)
- }
- pub fn is_byte_aligned(&self) -> bool {
- self.pos.is_multiple_of(8)
- }
- pub fn byte_align(&mut self) {
- self.pos = self.pos.div_ceil(8) * 8;
- }
- /// Byte offset of the current position (rounded down).
- pub fn byte_pos(&self) -> usize {
- self.pos / 8
- }
- pub fn seek_bits(&mut self, pos: usize) {
- self.pos = pos;
- }
-
- /// Peek up to 32 bits without consuming (zero-padded past the end).
- #[inline]
- pub fn peek(&self, n: u32) -> u32 {
- debug_assert!(n <= 32);
- if n == 0 {
- return 0;
- }
- let byte = self.pos / 8;
- let shift = self.pos % 8;
- let mut v: u64 = 0;
- for i in 0..5 {
- v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64;
- }
- // v holds 40 bits starting at `byte`
- ((v << (24 + shift)) >> (64 - n)) as u32
- }
-
- #[inline]
- pub fn read_bits(&mut self, n: u32) -> Result {
- if n as usize > self.bits_left() {
- return Err(BitError::Eof);
- }
- let v = self.peek(n);
- self.pos += n as usize;
- Ok(v)
- }
-
- pub fn read_bits_u64(&mut self, n: u32) -> Result {
- if n <= 32 {
- return self.read_bits(n).map(u64::from);
- }
- let hi = self.read_bits(n - 32)? as u64;
- let lo = self.read_bits(32)? as u64;
- Ok((hi << 32) | lo)
- }
-
- #[inline]
- pub fn read_bit(&mut self) -> Result {
- self.read_bits(1).map(|b| b == 1)
- }
- pub fn read_flag(&mut self) -> Result {
- self.read_bit()
- }
-
- pub fn skip(&mut self, n: usize) -> Result<()> {
- if n > self.bits_left() {
- return Err(BitError::Eof);
- }
- self.pos += n;
- Ok(())
- }
-
- /// Raw Exp-Golomb codeNum, up to 32 leading zeros (so below 2^33).
- fn read_code_num(&mut self) -> Result {
- let mut zeros = 0u32;
- while !self.read_bit()? {
- zeros += 1;
- if zeros > 32 {
- return Err(BitError::InvalidExpGolomb);
- }
- }
- if zeros == 0 {
- return Ok(0);
- }
- let v = self.read_bits_u64(zeros)?;
- Ok((1u64 << zeros) - 1 + v)
- }
-
- /// Unsigned Exp-Golomb `ue(v)`.
- pub fn read_ue(&mut self) -> Result {
- u32::try_from(self.read_code_num()?).map_err(|_| BitError::InvalidExpGolomb)
- }
-
- /// Signed Exp-Golomb `se(v)`.
- pub fn read_se(&mut self) -> Result {
- // codeNum 2^32 is -2^31, so the mapping is done before narrowing to 32 bits.
- let k = self.read_code_num()? as i64;
- let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
- i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
- }
-
- /// Truncated Exp-Golomb `te(v)` with range `max`.
- pub fn read_te(&mut self, max: u32) -> Result {
- if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) }
- }
-
- /// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
- pub fn more_rbsp_data(&self) -> bool {
- let total = self.data.len() * 8;
- if self.pos >= total {
- return false;
- }
- // find last set bit (the rbsp_stop_one_bit)
- let mut last = None;
- for (i, &b) in self.data.iter().enumerate().rev() {
- if b != 0 {
- last = Some(i * 8 + 7 - b.trailing_zeros() as usize);
- break;
- }
- }
- match last {
- Some(stop) => self.pos < stop,
- None => false,
- }
- }
-}
-
-/// MSB-first bit writer.
-#[derive(Default, Clone)]
-pub struct BitWriter {
- buf: Vec,
- acc: u64,
- nbits: u32,
-}
-
-impl BitWriter {
- pub fn new() -> Self {
- Self::default()
- }
- pub fn write_bits(&mut self, value: u32, n: u32) {
- debug_assert!(n <= 32);
- if n == 0 {
- return;
- }
- let v = (value as u64) & ((1u64 << n) - 1);
- self.acc = (self.acc << n) | v;
- self.nbits += n;
- while self.nbits >= 8 {
- self.nbits -= 8;
- self.buf.push((self.acc >> self.nbits) as u8);
- }
- self.acc &= (1u64 << self.nbits) - 1;
- }
- pub fn write_bit(&mut self, b: bool) {
- self.write_bits(b as u32, 1);
- }
- pub fn write_ue(&mut self, v: u32) {
- self.write_code_num(u64::from(v));
- }
- /// Exp-Golomb codeNum up to 2^32 (the code of `se(v)` -2^31).
- fn write_code_num(&mut self, v: u64) {
- let x = v + 1;
- let len = 64 - x.leading_zeros();
- let zeros = len - 1;
- for _ in 0..zeros {
- self.write_bit(false);
- }
- if len > 32 {
- self.write_bits((x >> 32) as u32, len - 32);
- self.write_bits(x as u32, 32);
- } else {
- self.write_bits(x as u32, len);
- }
- }
- pub fn write_se(&mut self, v: i32) {
- let m = u64::from(v.unsigned_abs()) * 2;
- self.write_code_num(if v > 0 { m - 1 } else { m });
- }
- pub fn bit_len(&self) -> usize {
- self.buf.len() * 8 + self.nbits as usize
- }
- pub fn is_byte_aligned(&self) -> bool {
- self.nbits == 0
- }
- /// Pad with zero bits to a byte boundary.
- pub fn align_zero(&mut self) {
- if self.nbits > 0 {
- self.write_bits(0, 8 - self.nbits);
- }
- }
- /// `rbsp_trailing_bits()`: a one bit then zeros to alignment.
- pub fn rbsp_trailing(&mut self) {
- self.write_bit(true);
- self.align_zero();
- }
- pub fn write_bytes(&mut self, bytes: &[u8]) {
- if self.nbits == 0 {
- self.buf.extend_from_slice(bytes);
- } else {
- for &b in bytes {
- self.write_bits(b as u32, 8);
- }
- }
- }
- pub fn finish(mut self) -> Vec {
- self.align_zero();
- self.buf
- }
- pub fn bytes(&self) -> &[u8] {
- &self.buf
- }
-}
-
-/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload.
-pub fn unescape_rbsp(data: &[u8]) -> Vec {
- let mut out = Vec::with_capacity(data.len());
- let mut zeros = 0;
- for &b in data {
- if zeros >= 2 && b == 3 {
- zeros = 0;
- continue;
- }
- out.push(b);
- zeros = if b == 0 { zeros + 1 } else { 0 };
- }
- out
-}
-
-/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears.
-pub fn escape_rbsp(data: &[u8]) -> Vec {
- let mut out = Vec::with_capacity(data.len() + data.len() / 64);
- let mut zeros = 0;
- for &b in data {
- if zeros >= 2 && b <= 3 {
- out.push(3);
- zeros = 0;
- }
- out.push(b);
- zeros = if b == 0 { zeros + 1 } else { 0 };
- }
- out
-}
-
-/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads).
-pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
- let mut starts = Vec::new();
- let mut i = 0;
- while i + 3 <= data.len() {
- if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
- starts.push(i + 3);
- i += 3;
- } else {
- i += 1;
- }
- }
- let mut out = Vec::with_capacity(starts.len());
- for (k, &s) in starts.iter().enumerate() {
- let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() };
- // trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits)
- while e > s && data[e - 1] == 0 {
- e -= 1;
- }
- if e > s {
- out.push(&data[s..e]);
- }
- }
- out
-}
-
-/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units.
-pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result> {
- let mut out = Vec::new();
- let mut i = 0;
- while i < data.len() {
- if i + length_size > data.len() {
- return Err(BitError::Eof);
- }
- let mut len = 0usize;
- for k in 0..length_size {
- len = (len << 8) | data[i + k] as usize;
- }
- i += length_size;
- if i + len > data.len() {
- return Err(BitError::Eof);
- }
- out.push(&data[i..i + len]);
- i += len;
- }
- Ok(out)
-}
-
-/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG).
-pub fn crc32(data: &[u8]) -> u32 {
- let mut crc = 0xFFFF_FFFFu32;
- for &b in data {
- crc ^= b as u32;
- for _ in 0..8 {
- crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
- }
- }
- !crc
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use proptest::prelude::*;
-
- #[test]
- fn reads_bits() {
- let d = [0b1010_1100, 0b0101_0011];
- let mut r = BitReader::new(&d);
- assert_eq!(r.read_bits(1).unwrap(), 1);
- assert_eq!(r.read_bits(3).unwrap(), 0b010);
- assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101);
- assert_eq!(r.bits_left(), 4);
- assert_eq!(r.read_bits(5), Err(BitError::Eof));
- }
-
- #[test]
- fn exp_golomb_known() {
- // 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4
- let mut w = BitWriter::new();
- for v in 0..5 {
- w.write_ue(v);
- }
- let b = w.finish();
- let mut r = BitReader::new(&b);
- for v in 0..5 {
- assert_eq!(r.read_ue().unwrap(), v);
- }
- let mut w = BitWriter::new();
- for v in [0, 1, -1, 2, -2, 1000, -1000] {
- w.write_se(v);
- }
- let b = w.finish();
- let mut r = BitReader::new(&b);
- for v in [0, 1, -1, 2, -2, 1000, -1000] {
- assert_eq!(r.read_se().unwrap(), v);
- }
- }
-
- #[test]
- fn signed_exp_golomb_extremes_roundtrip() {
- let vals = [0, 1, -1, i32::MAX, i32::MIN];
- let mut w = BitWriter::new();
- for v in vals {
- w.write_se(v);
- }
- let b = w.finish();
- let mut r = BitReader::new(&b);
- for v in vals {
- assert_eq!(r.read_se(), Ok(v));
- }
- }
-
- #[test]
- fn signed_exp_golomb_reads_minimum_from_spec_bits() {
- // 32 zero bits, 1, then the 32-bit suffix 1: codeNum = 2^32 -> -2^31.
- let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
- assert_eq!(BitReader::new(&bytes).read_se(), Ok(i32::MIN));
- }
-
- #[test]
- fn signed_exp_golomb_rejects_positive_overflow() {
- // 32 zero bits, 1, then 32 zero bits: codeNum = 2^32 - 1 -> +2^31, not an i32.
- let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0];
- assert_eq!(BitReader::new(&bytes).read_se(), Err(BitError::InvalidExpGolomb));
- }
-
- #[test]
- fn unsigned_exp_golomb_keeps_u32_range() {
- // codeNum = 2^32 is a valid se(v) code but does not fit ue(v)'s u32.
- let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
- assert_eq!(BitReader::new(&bytes).read_ue(), Err(BitError::InvalidExpGolomb));
- }
-
- #[test]
- fn emulation_prevention() {
- let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
- let esc = escape_rbsp(&raw);
- assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]);
- assert_eq!(unescape_rbsp(&esc), raw);
- }
-
- #[test]
- fn annexb_split() {
- let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9];
- let n = annexb_nals(&s);
- assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]);
- let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68];
- assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
- }
-
- #[test]
- fn more_rbsp() {
- let d = [0b1100_0000];
- let mut r = BitReader::new(&d);
- assert!(r.more_rbsp_data());
- r.read_bit().unwrap();
- assert!(!r.more_rbsp_data());
- }
-
- #[test]
- fn crc() {
- assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
- }
-
- proptest! {
- #[test]
- fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) {
- let mut w = BitWriter::new();
- for &(v, n) in &vals { w.write_bits(v, n); }
- for &u in &ues { w.write_ue(u); }
- let b = w.finish();
- let mut r = BitReader::new(&b);
- for &(v, n) in &vals {
- let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 };
- prop_assert_eq!(r.read_bits(n).unwrap(), v & mask);
- }
- for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); }
- }
-
- #[test]
- fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) {
- let e = escape_rbsp(&data);
- prop_assert_eq!(unescape_rbsp(&e), data);
- for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); }
- }
- }
-}
+pub use craft_media::bitstream::*;
diff --git a/crates/captions/README.md b/crates/captions/README.md
index 04183352c..8618cadac 100644
--- a/crates/captions/README.md
+++ b/crates/captions/README.md
@@ -85,7 +85,7 @@ implementation's code was consulted.
- Unit tests per module (timestamps, decoding, sloppy SRT, WebVTT blocks and voices, CEA-608
tables, PAC round trips, known SCC pop-on and roll-up streams, layout).
-- `tests/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers,
+- `tests/it/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers,
settings, STYLE blocks), SCC and MCC (drop-frame and non-drop, gaps and back-to-back captions;
MCC also through CEA-708 alone), EBU STL (25 and 29.97 fps, accents, extension blocks), TTML
(23.976 / 25 / 29.97 / 59.94, ids, speakers) round-trip frame-exactly; DFXP round-trips after
diff --git a/crates/captions/src/scc.rs b/crates/captions/src/scc.rs
index 1d2fafbfd..9fe275ed6 100644
--- a/crates/captions/src/scc.rs
+++ b/crates/captions/src/scc.rs
@@ -34,7 +34,7 @@ pub fn parse_timecode(s: &str) -> Option {
let df = s.contains([';', '.', ',']);
let parts: Vec<&str> = s.split([':', ';', '.', ',']).collect();
let [h, m, sec, f] = parts.as_slice() else { return None };
- let n = |p: &str| if (1..=3).contains(&p.len()) { p.parse::().ok() } else { None };
+ let n = |p: &str| if (1..=3).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_digit()) { p.parse::().ok() } else { None };
Some(fields_to_frames(n(h)?, n(m)?, n(sec)?, n(f)?, RATE, df))
}
@@ -484,6 +484,14 @@ mod tests {
Cue { start: RATE.tick_of(s), end: RATE.tick_of(e), text: text.into(), ..Default::default() }
}
+ #[test]
+ fn timecode_rejects_signed_fields() {
+ assert_eq!(parse_timecode("00:00:-5:00"), None);
+ assert_eq!(parse_timecode("00:+1:00:00"), None);
+ assert_eq!(parse_timecode("00:00:00:"), None);
+ assert_eq!(parse_timecode("00:00:01:00"), Some(30));
+ }
+
#[test]
fn timecodes() {
assert_eq!(parse_timecode("00:01:00;02"), Some(1800));
diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs
index bf6b2f65b..bbbbc6628 100644
--- a/crates/captions/src/ttml.rs
+++ b/crates/captions/src/ttml.rs
@@ -356,7 +356,7 @@ fn parse_time(s: &str, tm: Timing) -> Option {
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#"
diff --git a/crates/captions/src/vtt.rs b/crates/captions/src/vtt.rs
index a1afb2445..9c2f3ae4e 100644
--- a/crates/captions/src/vtt.rs
+++ b/crates/captions/src/vtt.rs
@@ -79,7 +79,7 @@ pub fn parse(text: &str) -> Result {
let mut seen_cue = false;
for b in blocks {
let first = b[0].trim();
- if !first.contains("-->") && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) {
+ if !b.iter().any(|l| l.contains("-->")) && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) {
doc.blocks.push(b.join("\n"));
continue;
}
@@ -190,6 +190,14 @@ mod tests {
assert_eq!(split_voice(closed), (None, closed.to_string()));
}
+ #[test]
+ fn note_prefixed_cue_id_is_a_cue() {
+ let d = parse("WEBVTT\n\nNOTE 1\n00:00:01.000 --> 00:00:02.000\nhello\n").unwrap();
+ assert_eq!(d.cues.len(), 1);
+ assert_eq!(d.cues[0].id.as_deref(), Some("NOTE 1"));
+ assert!(d.blocks.is_empty());
+ }
+
#[test]
fn without_signature() {
let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap();
diff --git a/crates/captions/tests/it/main.rs b/crates/captions/tests/it/main.rs
new file mode 100644
index 000000000..d890ff913
--- /dev/null
+++ b/crates/captions/tests/it/main.rs
@@ -0,0 +1,3 @@
+mod roundtrip;
+mod ttml_body_timing;
+mod ttml_speakers;
diff --git a/crates/captions/tests/roundtrip.rs b/crates/captions/tests/it/roundtrip.rs
similarity index 100%
rename from crates/captions/tests/roundtrip.rs
rename to crates/captions/tests/it/roundtrip.rs
diff --git a/crates/captions/tests/ttml_body_timing.rs b/crates/captions/tests/it/ttml_body_timing.rs
similarity index 100%
rename from crates/captions/tests/ttml_body_timing.rs
rename to crates/captions/tests/it/ttml_body_timing.rs
diff --git a/crates/captions/tests/ttml_speakers.rs b/crates/captions/tests/it/ttml_speakers.rs
similarity index 100%
rename from crates/captions/tests/ttml_speakers.rs
rename to crates/captions/tests/it/ttml_speakers.rs
diff --git a/crates/codecs/src/audio_timing_tests.rs b/crates/codecs/src/audio_timing_tests.rs
index 62f25cb10..e712f7052 100644
--- a/crates/codecs/src/audio_timing_tests.rs
+++ b/crates/codecs/src/audio_timing_tests.rs
@@ -50,6 +50,8 @@ fn mkv(asc: &[u8], rate: u32, units: &[Vec], lacing: LacingMode, gap: usize,
let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC");
spec.codec_private = asc.to_vec();
spec.audio = Some((rate as f64, 2, None));
+ // an access unit's duration, rounded to the nanosecond; without it the writer laces nothing
+ spec.default_duration_ns = Some((1024 * 1_000_000_000 + u64::from(rate) / 2) / u64::from(rate));
let opts = MuxOptions { lacing, ..MuxOptions::default() };
assert_eq!(opts.timestamp_scale, 1_000_000, "1 ms timestamps");
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], opts).unwrap();
@@ -116,6 +118,8 @@ fn matroska_millisecond_stamps_play_back_to_back() {
let frames = units.len().saturating_sub(1) * 1024;
for lacing in [LacingMode::None, LacingMode::Xiph] {
let bytes = mkv(&asc, rate, &units, lacing, usize::MAX, 0);
+ let laced = filmcraft_matroska::open(&bytes[..]).unwrap().tracks.iter().any(|t| t.samples.iter().any(|s| s.lace > 0));
+ assert_eq!(laced, !matches!(lacing, LacingMode::None), "{rate} Hz, {lacing:?}: laced blocks");
let src = MkvSource::open("tone.mkv", bytes.clone()).unwrap();
let got = played(&src, rate, frames);
let (n, first) = mismatches(&got, &want, 0);
diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs
index 522b46b43..bcfd4f7ec 100644
--- a/crates/codecs/src/gop.rs
+++ b/crates/codecs/src/gop.rs
@@ -123,6 +123,20 @@ pub trait VideoSamples: Sync {
fn make_decoder(&self) -> crate::Result>;
}
+/// A container's sync sample for `i` when leading pictures need the GOP before: `sync` is the
+/// index's own sync sample, `leads(i, key)` says `i` is shown before `key`. One step per GOP, so a
+/// loop: a crafted index can chain tens of thousands of GOPs (#2803). A `sync` past `i` stops the
+/// walk instead of looping on it.
+pub(crate) fn open_gop_sync(mut i: usize, sync: impl Fn(usize) -> usize, leads: impl Fn(usize, usize) -> bool) -> usize {
+ loop {
+ let key = sync(i);
+ if key == 0 || key > i || !leads(i, key) {
+ return key;
+ }
+ i = key - 1;
+ }
+}
+
/// The sync sample to start decoding at for sample `i`: its sync sample, or an earlier one when
/// `i` is a leading picture of an open GOP (stored after its random-access picture, shown before
/// it, like the RASL pictures after every HEVC CRA of iPhone video). Those are predicted from the
@@ -866,6 +880,19 @@ impl GopCache {
#[cfg(test)]
mod tests {
+ #[test]
+ fn open_gop_sync_walks_a_long_chain_of_leading_pictures_without_recursing() {
+ // Every GOP of 3 starts at a key whose leading picture is shown before it (#2803).
+ let gops = 200_000usize;
+ let sync = |i: usize| i - i % 3;
+ let leads = |i: usize, key: usize| i == key + 2;
+ assert_eq!(super::open_gop_sync(gops * 3 - 1, sync, leads), 0);
+ // A picture that is not a leading one keeps its own key.
+ assert_eq!(super::open_gop_sync(7, sync, |_, _| false), 6);
+ // An index whose sync sample lies past `i` ends the walk instead of looping.
+ assert_eq!(super::open_gop_sync(5, |_| 9, |_, _| true), 9);
+ }
+
use super::*;
use crate::video::DecodedFrame;
use std::collections::VecDeque;
diff --git a/crates/codecs/src/hw.rs b/crates/codecs/src/hw.rs
index d14545bb2..0ad34e5e5 100644
--- a/crates/codecs/src/hw.rs
+++ b/crates/codecs/src/hw.rs
@@ -8,6 +8,7 @@
//! decode, and a hardware decoder that fails mid-stream switches to
//! [`crate::software_video_decoder`], so registering one never makes a file undecodable.
+use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use filmcraft_isobmff::{CodecConfig, SampleEntry};
@@ -175,10 +176,14 @@ impl StreamInfo {
/// with (parameter sets, a sequence header, a key frame of another format).
pub fn parameters_changed(&self, sample: &[u8]) -> bool {
match self {
- Self::Nal(i) => i.nals(sample).into_iter().any(|n| {
- n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h)))
- && !i.parameter_sets.iter().any(|p| without_trailing_zeros(p) == without_trailing_zeros(n))
- }),
+ Self::Nal(i) => {
+ let configured: HashSet<&[u8]> = i.parameter_sets.iter().map(|p| without_trailing_zeros(p)).collect();
+ let mut checked = HashSet::new();
+ i.nals(sample).into_iter().any(|n| {
+ let n = without_trailing_zeros(n);
+ n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) && checked.insert(n) && !configured.contains(n)
+ })
+ }
Self::Frame(i) => i.parameters_changed(sample),
}
}
@@ -456,4 +461,16 @@ mod tests {
assert!(info.parameters_changed(&sample(&hvcc_pps[..6])), "a missing byte that is not zero is a change");
assert!(info.parameters_changed(&sample(&[0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x91])), "another PPS is a change");
}
+
+ #[test]
+ fn repeated_parameter_sets_are_checked_once_per_distinct_nal() {
+ let pps = vec![0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x90];
+ let info = StreamInfo::Nal(hevc_info(std::iter::repeat_n(pps.clone(), 256).collect()));
+ let mut sample = Vec::new();
+ for _ in 0..256 {
+ sample.extend_from_slice(&(pps.len() as u32).to_be_bytes());
+ sample.extend_from_slice(&pps);
+ }
+ assert!(!info.parameters_changed(&sample));
+ }
}
diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs
index 2f51258a8..f2083b6d4 100644
--- a/crates/codecs/src/mkv.rs
+++ b/crates/codecs/src/mkv.rs
@@ -231,7 +231,7 @@ fn vp9_config(private: &[u8], v: Option<&filmcraft_matroska::VideoInfo>) -> VpcC
}
p += 2 + len;
}
- if let Some(col) = v.and_then(|v| v.colour.as_ref()) {
+ if let Some(col) = v.and_then(|v| v.color.as_ref()) {
if let Some(t) = col.transfer_characteristics {
c.transfer_characteristics = t as u8;
}
@@ -265,7 +265,7 @@ fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoI
}
/// HDR static metadata from the Matroska `Colour` element.
-fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option {
+fn hdr_metadata(c: &filmcraft_matroska::Color) -> Option {
if c.mastering.is_none() && c.max_cll.is_none() && c.max_fall.is_none() {
return None;
}
@@ -285,7 +285,7 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option) -> (ColorInfo, bool) {
// H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here
let code = |c: Option| c.unwrap_or(2);
- let container = v.colour.as_ref().map(|col| {
+ let container = v.color.as_ref().map(|col| {
let range = match col.range {
Some(1) => Some(false),
Some(2) => Some(true),
@@ -404,7 +404,7 @@ impl MkvSource {
color,
has_alpha: v.alpha_mode != 0,
bitrate,
- hdr: v.colour.as_ref().and_then(hdr_metadata),
+ hdr: v.color.as_ref().and_then(hdr_metadata),
}
});
let audio_streams: Vec = atracks
diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs
index 6e33c027e..2f4328583 100644
--- a/crates/codecs/src/mp4.rs
+++ b/crates/codecs/src/mp4.rs
@@ -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>,
+ failed: bool,
+}
+
struct AudioState {
decoder: Option,
/// Decoded packets by sample index.
- packets: HashMap>>>,
+ packets: HashMap>,
order: Vec,
last_decoded: Option,
}
@@ -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 {
+ 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 {
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>>> {
+ fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result> {
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::>().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 = (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]);
+ }
+}
diff --git a/crates/codecs/src/mpeg.rs b/crates/codecs/src/mpeg.rs
index e2cd09ba1..040ca9092 100644
--- a/crates/codecs/src/mpeg.rs
+++ b/crates/codecs/src/mpeg.rs
@@ -369,15 +369,7 @@ impl MpegSource {
let raw: Vec