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 icon: an engraved owl on a purple tile +

+

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> = v.order.iter().map(|&i| units[v.units[i]].pts).collect(); let rate = self.info.frame_rate(); let dur = |k: i64| (k as i128 * 90_000 * rate.den as i128 / rate.num.max(1) as i128) as i64; - let known: Vec = (0..raw.len()).filter(|&i| raw[i].is_some()).collect(); - (0..raw.len()) - .map(|i| { - raw[i].or_else(|| { - let j = known.iter().rev().find(|&&j| j < i).or_else(|| known.iter().find(|&&j| j > i))?; - Some(raw[*j]? + dur(i as i64 - *j as i64)) - }) - }) - .collect() + interpolate_pts(&raw, dur) } fn read_unit(&self, v: &VideoTrack, i: usize) -> crate::Result> { @@ -690,6 +682,23 @@ impl MpegSource { } } +/// Fill missing timestamps from their closest preceding timestamp, or the next one when there is +/// no predecessor. The known positions are sorted, so binary search avoids rescanning them for +/// every missing frame. +fn interpolate_pts(raw: &[Option], dur: impl Fn(i64) -> i64) -> Vec> { + let known: Vec = (0..raw.len()).filter(|&i| raw[i].is_some()).collect(); + raw.iter() + .enumerate() + .map(|(i, &pts)| { + pts.or_else(|| { + let next = known.partition_point(|&j| j < i); + let j = next.checked_sub(1).or_else(|| known.get(next).map(|_| next))?; + Some(raw[known[j]]? + dur(i as i64 - known[j] as i64)) + }) + }) + .collect() +} + fn chroma_name(c: filmcraft_frame::Chroma) -> &'static str { match c { filmcraft_frame::Chroma::C420 => "4:2:0", @@ -760,10 +769,15 @@ impl VideoSamples for MpegVideo<'_> { } fn sync_before(&self, i: usize) -> usize { let i = i.min(self.v.keys.len().saturating_sub(1)); - let key = (0..=i).rev().find(|&k| self.v.keys[k]).unwrap_or(0); + let keys = &self.v.keys; + let pts = &self.v.pts; // a leading picture of an open GOP (shown before its random-access picture) needs the // previous GOP's pictures as references - if key > 0 && self.v.pts[i] < self.v.pts[key] { self.sync_before(key - 1) } else { key } + crate::gop::open_gop_sync( + i, + |i| (0..=i).rev().find(|&k| keys.get(k) == Some(&true)).unwrap_or(0), + |i, key| matches!((pts.get(i), pts.get(key)), (Some(a), Some(b)) if a < b), + ) } fn sample_at(&self, t: i64) -> Option { usize::try_from(t).ok().and_then(|t| self.v.order.get(t).copied()) @@ -1015,4 +1029,16 @@ mod tests { assert_eq!(order, vec![0, 4, 5, 3]); assert_eq!(pts, vec![0, -2, -3, 3, 1, 2]); } + + #[test] + fn timestamp_interpolation_handles_long_missing_runs_without_rescanning() { + let mut raw = vec![None; 10_000]; + raw[4_000] = Some(40_000); + raw[9_999] = Some(99_990); + let pts = interpolate_pts(&raw, |frames| frames * 10); + assert_eq!(pts[0], Some(0)); + assert_eq!(pts[3_999], Some(39_990)); + assert_eq!(pts[4_001], Some(40_010)); + assert_eq!(pts[9_998], Some(99_980)); + } } diff --git a/crates/codecs/src/mxf.rs b/crates/codecs/src/mxf.rs index 1b230a8dc..cf8900a10 100644 --- a/crates/codecs/src/mxf.rs +++ b/crates/codecs/src/mxf.rs @@ -408,10 +408,11 @@ impl VideoSamples for MxfVideo<'_> { self.src.vpts[i] } fn sync_before(&self, i: usize) -> usize { - let key = self.src.file.tracks[self.track].sync_before(i); // a leading picture of an open GOP (stored after its random-access picture, shown // before it) is predicted from the previous GOP too - if key > 0 && i < self.src.vpts.len() && self.src.vpts[i] < self.src.vpts[key] { self.sync_before(key - 1) } else { key } + let track = &self.src.file.tracks[self.track]; + let pts = &self.src.vpts; + crate::gop::open_gop_sync(i, |i| track.sync_before(i), |i, key| matches!((pts.get(i), pts.get(key)), (Some(a), Some(b)) if a < b)) } fn sample_at(&self, t: i64) -> Option { usize::try_from(t).ok().and_then(|t| self.src.vorder.get(t).copied()) diff --git a/crates/codecs/tests/aac_channels_oracle.rs b/crates/codecs/tests/it/aac_channels_oracle.rs similarity index 99% rename from crates/codecs/tests/aac_channels_oracle.rs rename to crates/codecs/tests/it/aac_channels_oracle.rs index 5f850ac3e..43bcf6a65 100644 --- a/crates/codecs/tests/aac_channels_oracle.rs +++ b/crates/codecs/tests/it/aac_channels_oracle.rs @@ -3,7 +3,7 @@ //! both 7.1 (eight channels) and 11 is 6.1 (seven). Each channel carries its own tone, so a //! dropped or swapped channel shows up against ffmpeg's decode. -mod common; +use crate::common; use std::io::Cursor; diff --git a/crates/codecs/tests/adts_file.rs b/crates/codecs/tests/it/adts_file.rs similarity index 100% rename from crates/codecs/tests/adts_file.rs rename to crates/codecs/tests/it/adts_file.rs diff --git a/crates/codecs/tests/common/mod.rs b/crates/codecs/tests/it/common/mod.rs similarity index 100% rename from crates/codecs/tests/common/mod.rs rename to crates/codecs/tests/it/common/mod.rs diff --git a/crates/codecs/tests/draft_decoders.rs b/crates/codecs/tests/it/draft_decoders.rs similarity index 99% rename from crates/codecs/tests/draft_decoders.rs rename to crates/codecs/tests/it/draft_decoders.rs index bec6476f9..b656dea50 100644 --- a/crates/codecs/tests/draft_decoders.rs +++ b/crates/codecs/tests/it/draft_decoders.rs @@ -2,7 +2,7 @@ //! off by default, approximate frames only reach draft requests, and decoding for an export after //! draft playback (same source, same GOP cache) still matches ffmpeg bit for bit. -mod common; +use crate::common; use common::*; use filmcraft_media::FrameRequest; diff --git a/crates/codecs/tests/eac3_oracle.rs b/crates/codecs/tests/it/eac3_oracle.rs similarity index 99% rename from crates/codecs/tests/eac3_oracle.rs rename to crates/codecs/tests/it/eac3_oracle.rs index 7d875d49c..e75476db7 100644 --- a/crates/codecs/tests/eac3_oracle.rs +++ b/crates/codecs/tests/it/eac3_oracle.rs @@ -2,7 +2,7 @@ //! and muxes the files and decodes them as the oracle. Zero-bit mantissas carry decoder-specific //! dither (A/52 §7.3.4), so the criterion is SNR against ffmpeg's decode, sample-aligned. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/frame_color_oracle.rs b/crates/codecs/tests/it/frame_color_oracle.rs similarity index 99% rename from crates/codecs/tests/frame_color_oracle.rs rename to crates/codecs/tests/it/frame_color_oracle.rs index 7da3baf09..15740fc3e 100644 --- a/crates/codecs/tests/frame_color_oracle.rs +++ b/crates/codecs/tests/it/frame_color_oracle.rs @@ -2,7 +2,7 @@ //! MP4 without a `colr` box unless asked, so for most files the bitstream is the only place the //! primaries are described: stream info already read them, the decoded frames reported BT.709. -mod common; +use crate::common; use common::*; use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer}; diff --git a/crates/codecs/tests/h264_draft.rs b/crates/codecs/tests/it/h264_draft.rs similarity index 99% rename from crates/codecs/tests/h264_draft.rs rename to crates/codecs/tests/it/h264_draft.rs index 81e8c3bb8..82721ee30 100644 --- a/crates/codecs/tests/h264_draft.rs +++ b/crates/codecs/tests/it/h264_draft.rs @@ -2,7 +2,7 @@ //! by default, its approximate frames only ever reach draft requests, and decoding for an export //! after draft playback (same source, same GOP cache) still matches ffmpeg bit for bit. -mod common; +use crate::common; use common::*; use filmcraft_media::FrameRequest; diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs new file mode 100644 index 000000000..679dcaa6b --- /dev/null +++ b/crates/codecs/tests/it/main.rs @@ -0,0 +1,17 @@ +mod aac_channels_oracle; +mod adts_file; +mod common; +mod draft_decoders; +mod eac3_oracle; +mod frame_color_oracle; +mod h264_draft; +mod matroska_audio_oracle; +mod media_oracle; +mod mkv_mp1_oracle; +mod mp4_oracle; +mod mp4_rtmd; +mod mpeg_oracle; +mod mxf_oracle; +mod ogg_oracle; +mod rf64_metadata; +mod y4m_file; diff --git a/crates/codecs/tests/matroska_audio_oracle.rs b/crates/codecs/tests/it/matroska_audio_oracle.rs similarity index 99% rename from crates/codecs/tests/matroska_audio_oracle.rs rename to crates/codecs/tests/it/matroska_audio_oracle.rs index 3ded7af07..c2cfbd45e 100644 --- a/crates/codecs/tests/matroska_audio_oracle.rs +++ b/crates/codecs/tests/it/matroska_audio_oracle.rs @@ -4,7 +4,7 @@ //! reads it, a tone must stay as smooth as ffmpeg's decode of it: no holes or overlaps between //! packets. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/it/media_oracle.rs similarity index 99% rename from crates/codecs/tests/media_oracle.rs rename to crates/codecs/tests/it/media_oracle.rs index da95c5eb6..5091c1dc3 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/it/media_oracle.rs @@ -4,7 +4,7 @@ //! exactly, JPEG within a few levels, high bit depths within one 8-bit step), and the BWF //! TimeReference must give the start timecode. -mod common; +use crate::common; use std::path::Path; use std::sync::Arc; diff --git a/crates/codecs/tests/mkv_mp1_oracle.rs b/crates/codecs/tests/it/mkv_mp1_oracle.rs similarity index 99% rename from crates/codecs/tests/mkv_mp1_oracle.rs rename to crates/codecs/tests/it/mkv_mp1_oracle.rs index 05d9810fb..aa142457c 100644 --- a/crates/codecs/tests/mkv_mp1_oracle.rs +++ b/crates/codecs/tests/it/mkv_mp1_oracle.rs @@ -2,7 +2,7 @@ //! installed encoder writes Layer I, so the frames are synthetic (valid random Layer I frames, //! as in `crates/mpa/tests/oracle.rs`) and ffmpeg only remuxes them into Matroska and decodes. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/it/mp4_oracle.rs similarity index 99% rename from crates/codecs/tests/mp4_oracle.rs rename to crates/codecs/tests/it/mp4_oracle.rs index cbb71ba0e..892598daf 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/it/mp4_oracle.rs @@ -2,7 +2,7 @@ //! priming), and reads at another sample rate must land on the same source samples as the native //! decode, however the requests are cut. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/mp4_rtmd.rs b/crates/codecs/tests/it/mp4_rtmd.rs similarity index 100% rename from crates/codecs/tests/mp4_rtmd.rs rename to crates/codecs/tests/it/mp4_rtmd.rs diff --git a/crates/codecs/tests/mpeg_oracle.rs b/crates/codecs/tests/it/mpeg_oracle.rs similarity index 99% rename from crates/codecs/tests/mpeg_oracle.rs rename to crates/codecs/tests/it/mpeg_oracle.rs index e6d5a39dd..bf58294e8 100644 --- a/crates/codecs/tests/mpeg_oracle.rs +++ b/crates/codecs/tests/it/mpeg_oracle.rs @@ -4,7 +4,7 @@ //! random seeks give the frames of the sequential decode. Audio: LPCM sample-exact, MPEG audio //! and AAC within a float-decoder tolerance, starting at the same sample. -mod common; +use crate::common; use std::path::{Path, PathBuf}; diff --git a/crates/codecs/tests/mxf_oracle.rs b/crates/codecs/tests/it/mxf_oracle.rs similarity index 99% rename from crates/codecs/tests/mxf_oracle.rs rename to crates/codecs/tests/it/mxf_oracle.rs index 5a965b814..23d882755 100644 --- a/crates/codecs/tests/mxf_oracle.rs +++ b/crates/codecs/tests/it/mxf_oracle.rs @@ -2,7 +2,7 @@ //! oracle. Our decode must match frame for frame (H.264 bit-exact, VC-3 ±2, ProRes ±1 LSB), seek //! to random frames exactly, and give sample-exact PCM. -mod common; +use crate::common; use std::path::{Path, PathBuf}; diff --git a/crates/codecs/tests/ogg_oracle.rs b/crates/codecs/tests/it/ogg_oracle.rs similarity index 99% rename from crates/codecs/tests/ogg_oracle.rs rename to crates/codecs/tests/it/ogg_oracle.rs index 939407756..be02870ed 100644 --- a/crates/codecs/tests/ogg_oracle.rs +++ b/crates/codecs/tests/it/ogg_oracle.rs @@ -4,7 +4,7 @@ //! `filmcraft-opus`'s own oracle; Vorbis: SNR ≥ 60 dB), and any random window must equal the //! continuous decode (granule-position seeking with pre-roll). -mod common; +use crate::common; use std::path::{Path, PathBuf}; diff --git a/crates/codecs/tests/rf64_metadata.rs b/crates/codecs/tests/it/rf64_metadata.rs similarity index 100% rename from crates/codecs/tests/rf64_metadata.rs rename to crates/codecs/tests/it/rf64_metadata.rs diff --git a/crates/codecs/tests/y4m_file.rs b/crates/codecs/tests/it/y4m_file.rs similarity index 100% rename from crates/codecs/tests/y4m_file.rs rename to crates/codecs/tests/it/y4m_file.rs diff --git a/crates/codecs/tests/pool_bitexact.rs b/crates/codecs/tests/pool_bitexact.rs index a90ec658e..1261e9a2b 100644 --- a/crates/codecs/tests/pool_bitexact.rs +++ b/crates/codecs/tests/pool_bitexact.rs @@ -3,6 +3,7 @@ //! primed with buffers full of 0xAA before decoding, and every frame must still match ffmpeg bit //! for bit. (Its own process: the pool is global.) +#[path = "it/common/mod.rs"] mod common; use std::sync::Arc; diff --git a/crates/color/README.md b/crates/color/README.md index 32c3edee8..e78774322 100644 --- a/crates/color/README.md +++ b/crates/color/README.md @@ -38,6 +38,8 @@ for limited- and full-range files. | ARRI LogC4 | ARRI Wide Gamut 4 | ARRI, *ARRI LogC4 Logarithmic Color Space — Specification* (2022) | CV | a = (2¹⁸ − 16)/117.45, b = 928/1023, c = 95/1023, s = 7·ln2·2^(7 − 14c/b)/(a·b), t = (2^(6 − 14c/b) − 64)/a; y ≥ 0: x = (2^(14(y − c)/b + 6) − 64)/a; else x = y·s + t | | Apple Log | BT.2020 | Apple, *Apple Log Profile* white paper (2023) | IRE | R0 = −0.05641088, Rt = 0.01, c = 47.28711236, β = 0.00964052, γ = 0.08550479, δ = 0.69336945; P ≥ c(Rt − R0)²: x = 2^((P − δ)/γ) − β; else x = √(P/c) + R0 | | DJI D-Log | D-Gamut | DJI, *White Paper on D-Log and D-Gamut of DJI Cinema Color System* (2017) | CV | y ≤ 0.14: x = (y − 0.0929)/6.025; else x = (10^(3.89616y − 2.27752) − 0.0108)/0.9892 | +| Fujifilm F-Log | F-Gamut (= BT.2020 primaries) | Fujifilm, *GFX ETERNA Technical Guide – White Paper* v1.01 (2026), F-Log conversion formula (same as the F-Log data sheet) | CV | y ≥ cut2: x = 10^((y − d)/c)/a − b/a; else x = (y − f)/e; a 0.555556, b 0.009468, c 0.344676, d 0.790453, e 8.735631, f 0.092864, cut1 0.00089, cut2 0.100537775223865 | +| Fujifilm F-Log2 / F-Log2 C | F-Gamut / F-Gamut C (R 0.7347, 0.2653; G 0.0263, 0.9737; B 0.1173, −0.0224) | same document, F-Log2 C / F-Log2 conversion formula | CV | same form; a 5.555556, b 0.064829, c 0.245281, d 0.384316, e 8.799461, f 0.092864, cut1 0.000889, cut2 0.100686685370811 | The encoders are the exact inverses given in the same documents. DJI **D-Log M** is not implemented: DJI has not published its formula (only conversion LUTs), and we do not derive curves diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index ce3340162..0c271a3a8 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -296,7 +296,11 @@ pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] { /// Parse `#rrggbb` / `#rrggbbaa` into 0..1 floats. pub fn parse_hex(s: &str) -> Option<[f32; 4]> { - let s = s.trim().trim_start_matches('#'); + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); + if !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| v as f32 / 255.0); match s.len() { 6 => Some([b(0)?, b(2)?, b(4)?, 1.0]), @@ -318,6 +322,16 @@ pub fn to_hex(c: [f32; 4]) -> String { mod tests { use super::*; + #[test] + fn parse_hex_rejects_signs() { + assert_eq!(parse_hex("#+f+f+f"), None); + assert_eq!(parse_hex("#-f-f-f"), None); + assert_eq!(parse_hex("##0f0f0f"), None); + assert_eq!(parse_hex("#0f0f0"), None); + assert!(parse_hex("#0f0f0f").is_some()); + assert!(parse_hex("0f0f0f80").is_some()); + } + #[test] fn ycbcr_roundtrip() { for m in [Matrix::Bt601, Matrix::Bt709, Matrix::Bt2020Ncl] { diff --git a/crates/color/src/log.rs b/crates/color/src/log.rs index 7fd23afa8..ec5967518 100644 --- a/crates/color/src/log.rs +++ b/crates/color/src/log.rs @@ -42,11 +42,25 @@ pub enum LogCurve { AppleLog, /// DJI D-Log. DLog, + /// Fujifilm F-Log. + FLog, + /// Fujifilm F-Log2 (also the curve of F-Log2 C). + FLog2, } impl LogCurve { - pub const ALL: [LogCurve; 8] = - [LogCurve::SLog3, LogCurve::VLog, LogCurve::CLog2, LogCurve::CLog3, LogCurve::LogC3, LogCurve::LogC4, LogCurve::AppleLog, LogCurve::DLog]; + pub const ALL: [LogCurve; 10] = [ + LogCurve::SLog3, + LogCurve::VLog, + LogCurve::CLog2, + LogCurve::CLog3, + LogCurve::LogC3, + LogCurve::LogC4, + LogCurve::AppleLog, + LogCurve::DLog, + LogCurve::FLog, + LogCurve::FLog2, + ]; pub fn domain(self) -> Domain { match self { @@ -65,6 +79,8 @@ impl LogCurve { LogCurve::LogC4 => "ARRI LogC4", LogCurve::AppleLog => "Apple Log", LogCurve::DLog => "DJI D-Log", + LogCurve::FLog => "Fujifilm F-Log", + LogCurve::FLog2 => "Fujifilm F-Log2", } } @@ -79,6 +95,8 @@ impl LogCurve { LogCurve::LogC4 => logc4_encode(x), LogCurve::AppleLog => apple_log_encode(x), LogCurve::DLog => dlog_encode(x), + LogCurve::FLog => fujifilm_encode(&FLOG, x), + LogCurve::FLog2 => fujifilm_encode(&FLOG2, x), } } @@ -93,6 +111,8 @@ impl LogCurve { LogCurve::LogC4 => logc4_decode(y), LogCurve::AppleLog => apple_log_decode(y), LogCurve::DLog => dlog_decode(y), + LogCurve::FLog => fujifilm_decode(&FLOG, y), + LogCurve::FLog2 => fujifilm_decode(&FLOG2, y), } } @@ -262,6 +282,35 @@ pub fn dlog_decode(y: f64) -> f64 { if y <= 0.14 { (y - 0.0929) / 6.025 } else { (10f64.powf(3.896_16 * y - 2.277_52) - 0.0108) / 0.9892 } } +// ---------------------------------------------------------------- Fujifilm F-Log / F-Log2 +// Fujifilm, "GFX ETERNA Technical Guide - White Paper" v1.01 (2026), F-Log / F-Log2 C / F-Log2 +// conversion formulas (the same as the F-Log and F-Log2 data sheets). `in` is scene-linear +// reflection, `out` 10-bit code value / 1023: 0 % → 95, 18 % → 470 (F-Log) / 400 (F-Log2). + +/// a, b, c, d, e, f, cut1 (encoding: scene-linear), cut2 (decoding: signal). +struct Fujifilm { + a: f64, + b: f64, + c: f64, + d: f64, + e: f64, + f: f64, + cut1: f64, + cut2: f64, +} + +const FLOG: Fujifilm = + Fujifilm { a: 0.555_556, b: 0.009_468, c: 0.344_676, d: 0.790_453, e: 8.735_631, f: 0.092_864, cut1: 0.000_89, cut2: 0.100_537_775_223_865 }; +const FLOG2: Fujifilm = + Fujifilm { a: 5.555_556, b: 0.064_829, c: 0.245_281, d: 0.384_316, e: 8.799_461, f: 0.092_864, cut1: 0.000_889, cut2: 0.100_686_685_370_811 }; + +fn fujifilm_encode(k: &Fujifilm, x: f64) -> f64 { + if x >= k.cut1 { k.c * (k.a * x + k.b).log10() + k.d } else { k.e * x + k.f } +} +fn fujifilm_decode(k: &Fujifilm, y: f64) -> f64 { + if y >= k.cut2 { 10f64.powf((y - k.d) / k.c) / k.a - k.b / k.a } else { (y - k.f) / k.e } +} + #[cfg(test)] mod tests { use super::*; @@ -317,6 +366,16 @@ mod tests { // DJI D-Log: 0 → 0.0929, 18 % → 0.398 (white paper). close(LogCurve::DLog.encode(0.0), 0.0929, 1e-9, "D-Log 0"); close(LogCurve::DLog.encode(0.18), 0.398, 0.002, "D-Log 18%"); + // Fujifilm (white paper code-value table): 0 % → 95, 18 % → 470 / 400, 90 % → 705 / 570. + close(cv(LogCurve::FLog, 0.0), 95.0, 0.5, "F-Log 0%"); + close(cv(LogCurve::FLog, 0.18), 470.0, 0.5, "F-Log 18%"); + close(cv(LogCurve::FLog, 0.9), 705.0, 0.6, "F-Log 90%"); + close(cv(LogCurve::FLog2, 0.0), 95.0, 0.5, "F-Log2 0%"); + close(cv(LogCurve::FLog2, 0.18), 400.0, 0.5, "F-Log2 18%"); + close(cv(LogCurve::FLog2, 0.9), 570.0, 0.5, "F-Log2 90%"); + // the decoding cut is where the encoding's two pieces meet (within the published rounding) + close(LogCurve::FLog.encode(0.000_89), 0.100_537_775_223_865, 2e-4, "F-Log cut"); + close(LogCurve::FLog2.encode(0.000_889), 0.100_686_685_370_811, 2e-4, "F-Log2 cut"); } #[test] diff --git a/crates/color/src/spaces.rs b/crates/color/src/spaces.rs index 0dd93f271..e4a994df1 100644 --- a/crates/color/src/spaces.rs +++ b/crates/color/src/spaces.rs @@ -34,6 +34,8 @@ pub enum Gamut { Awg4, /// DJI D-Gamut. DGamut, + /// Fujifilm F-Gamut C (F-Log2 C). F-Gamut itself has the BT.2020 primaries. + FGamutC, } impl Gamut { @@ -51,6 +53,7 @@ impl Gamut { Gamut::Awg3 => [[0.6840, 0.3130], [0.2210, 0.8480], [0.0861, -0.1020]], Gamut::Awg4 => [[0.7347, 0.2653], [0.1424, 0.8576], [0.0991, -0.0308]], Gamut::DGamut => [[0.71, 0.31], [0.21, 0.88], [0.09, -0.08]], + Gamut::FGamutC => [[0.7347, 0.2653], [0.0263, 0.9737], [0.1173, -0.0224]], } } @@ -165,12 +168,18 @@ pub enum ColorSpace { /// Apple Log (BT.2020 primaries). AppleLog, DLogDGamut, + /// Fujifilm F-Log / F-Gamut (BT.2020 primaries). + FLogFGamut, + /// Fujifilm F-Log2 / F-Gamut (BT.2020 primaries). + FLog2FGamut, + /// Fujifilm F-Log2 C / F-Gamut C. + FLog2CFGamutC, /// Linear light with BT.709 primaries (EXR-style float sources). Linear709, } impl ColorSpace { - pub const ALL: [ColorSpace; 16] = [ + pub const ALL: [ColorSpace; 19] = [ ColorSpace::Rec709, ColorSpace::Srgb, ColorSpace::Rec2020, @@ -186,6 +195,9 @@ impl ColorSpace { ColorSpace::LogC4Awg4, ColorSpace::AppleLog, ColorSpace::DLogDGamut, + ColorSpace::FLogFGamut, + ColorSpace::FLog2FGamut, + ColorSpace::FLog2CFGamutC, ColorSpace::Linear709, ]; @@ -207,6 +219,9 @@ impl ColorSpace { ColorSpace::LogC4Awg4 => "logc4-awg4", ColorSpace::AppleLog => "applelog", ColorSpace::DLogDGamut => "dlog-dgamut", + ColorSpace::FLogFGamut => "flog-fgamut", + ColorSpace::FLog2FGamut => "flog2-fgamut", + ColorSpace::FLog2CFGamutC => "flog2c-fgamutc", ColorSpace::Linear709 => "linear709", } } @@ -228,6 +243,9 @@ impl ColorSpace { ColorSpace::LogC4Awg4 => "ARRI LogC4 / ARRI Wide Gamut 4", ColorSpace::AppleLog => "Apple Log / Rec. 2020", ColorSpace::DLogDGamut => "DJI D-Log / D-Gamut", + ColorSpace::FLogFGamut => "Fujifilm F-Log / F-Gamut", + ColorSpace::FLog2FGamut => "Fujifilm F-Log2 / F-Gamut", + ColorSpace::FLog2CFGamutC => "Fujifilm F-Log2 C / F-Gamut C", ColorSpace::Linear709 => "Linear (Rec. 709 primaries)", } } @@ -242,7 +260,9 @@ impl ColorSpace { pub fn gamut(self) -> Gamut { match self { ColorSpace::Rec709 | ColorSpace::Srgb | ColorSpace::Linear709 => Gamut::Bt709, - ColorSpace::Rec2020 | ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg | ColorSpace::AppleLog => Gamut::Bt2020, + ColorSpace::Rec2020 | ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg | ColorSpace::AppleLog | ColorSpace::FLogFGamut | ColorSpace::FLog2FGamut => { + Gamut::Bt2020 + } ColorSpace::P3D65 => Gamut::P3D65, ColorSpace::SLog3SGamut3Cine => Gamut::SGamut3Cine, ColorSpace::SLog3SGamut3 => Gamut::SGamut3, @@ -251,6 +271,7 @@ impl ColorSpace { ColorSpace::LogC3Awg3 => Gamut::Awg3, ColorSpace::LogC4Awg4 => Gamut::Awg4, ColorSpace::DLogDGamut => Gamut::DGamut, + ColorSpace::FLog2CFGamutC => Gamut::FGamutC, } } @@ -269,6 +290,8 @@ impl ColorSpace { ColorSpace::LogC4Awg4 => Curve::Log(LogCurve::LogC4), ColorSpace::AppleLog => Curve::Log(LogCurve::AppleLog), ColorSpace::DLogDGamut => Curve::Log(LogCurve::DLog), + ColorSpace::FLogFGamut => Curve::Log(LogCurve::FLog), + ColorSpace::FLog2FGamut | ColorSpace::FLog2CFGamutC => Curve::Log(LogCurve::FLog2), } } diff --git a/crates/dng/README.md b/crates/dng/README.md index 4c2b1e7e9..c8678c1d3 100644 --- a/crates/dng/README.md +++ b/crates/dng/README.md @@ -73,14 +73,14 @@ restart intervals that split a line, and MXF-wrapped CinemaDNG. ## Tests -- `tests/roundtrip.rs`: container parsing in both byte orders with and without a preview IFD; +- `tests/it/roundtrip.rs`: container parsing in both byte orders with and without a preview IFD; uncompressed samples bit-exact at 8–16 bits, strips and tiles; lossless JPEG bit-exact for every predictor and component count, full-swing 16-bit data (every difference category, SSSS = 16), restarts and point transform; developed values against hand-computed ones (black / white, white balance, ForwardMatrix, BaselineExposure, ActiveArea, DefaultCrop, LinearRaw); CinemaDNG FrameRate / TimeCodes / ReelName; hostile files (every truncation, 12 000 mutations under `catch_unwind`, cyclic IFDs, huge sizes, bad offsets, zero denominators). -- `tests/oracle_libraw.rs`: LibRaw's `unprocessed_raw` reads the same samples from our synthetic +- `tests/it/oracle_libraw.rs`: LibRaw's `unprocessed_raw` reads the same samples from our synthetic files (uncompressed 8–16-bit in both byte orders, lossless JPEG predictors 1–7 with one and two components). Skipped when it is not installed. - Unit tests: matrix maths, the published sRGB matrix, McCamy temperatures, calibration diff --git a/crates/dng/tests/it/main.rs b/crates/dng/tests/it/main.rs new file mode 100644 index 000000000..fd498d961 --- /dev/null +++ b/crates/dng/tests/it/main.rs @@ -0,0 +1,2 @@ +mod oracle_libraw; +mod roundtrip; diff --git a/crates/dng/tests/oracle_libraw.rs b/crates/dng/tests/it/oracle_libraw.rs similarity index 100% rename from crates/dng/tests/oracle_libraw.rs rename to crates/dng/tests/it/oracle_libraw.rs diff --git a/crates/dng/tests/roundtrip.rs b/crates/dng/tests/it/roundtrip.rs similarity index 100% rename from crates/dng/tests/roundtrip.rs rename to crates/dng/tests/it/roundtrip.rs diff --git a/crates/dnx/README.md b/crates/dnx/README.md index 596ea9fa2..9f4656817 100644 --- a/crates/dnx/README.md +++ b/crates/dnx/README.md @@ -59,7 +59,7 @@ first frame header to label the stream. 12-bit coefficient domain is ¼ of the orthonormal DCT, as Table 14's 13-bit IDCT input range for 12-bit samples implies) and 1 otherwise. - Robustness: every offset and size is validated, run lengths past coefficient 63 are rejected, - and corrupted or truncated streams return `Err` without panicking (`tests/roundtrip.rs`). + and corrupted or truncated streams return `Err` without panicking (`tests/it/roundtrip.rs`). ### Where we follow ffmpeg rather than the letter of the standard @@ -100,7 +100,7 @@ the prose of §7.3.1.2; we follow the prose (normative text takes precedence). ## Accuracy (vs ffmpeg as an external oracle) -`tests/oracle_decode.rs` decodes 22 fixtures made by ffmpeg's VC-3 encoder: CIDs 1235, 1237, +`tests/it/oracle_decode.rs` decodes 22 fixtures made by ffmpeg's VC-3 encoder: CIDs 1235, 1237, 1238, 1241, 1242, 1243, 1244, 1250, 1251, 1252, 1253, 1258, 1259 and DNxHR LB/SQ/HQ/HQX/444 (Y′CbCr and RGB), with testsrc2, mandelbrot and heavy noise, at 1920×1080, 1280×720, 960×720, 1440×1080 (thin raster, interlaced), 1000×562, 640×360, 256×120 and 3840×2160. Compared with @@ -111,7 +111,7 @@ decoder evaluates the exact IDCT of equation 8.3, so decode is **not bit-exact, precision** of ffmpeg. (ffmpeg wraps rather than clips 10-bit reconstructions that overflow 1023, so the noise fixtures keep their samples inside 24…231 before encoding.) -`tests/oracle_encode.rs` encodes 1920×1080 frames with every profile and writes them into a +`tests/it/oracle_encode.rs` encodes 1920×1080 frames with every profile and writes them into a minimal test-only MOV. ffmpeg decodes them with `-xerror`, without errors, and agrees with our decoder within ±2 (90–99% identical). Luma PSNR against the source: HQ 58.8 dB (mandelbrot) and 66.3 dB (testsrc2), SQ 57.5 dB, LB 56.1 dB, HQX 10-bit 67.5 dB, HQX 12-bit 80.2 dB, 444 10-bit @@ -119,7 +119,7 @@ decoder within ±2 (90–99% identical). Luma PSNR against the source: HQ 58.8 d ## Performance -`cargo test --release -p filmcraft-dnx --test perf -- --ignored --nocapture` on an M4 Pro +`cargo test --release -p filmcraft-dnx --test it perf:: -- --ignored --nocapture` on an M4 Pro (14 cores) while the machine was **heavily overloaded** by other jobs (load average ≈ 190). Under the same load `filmcraft-prores`' perf test measured 30 fps threaded / 11.8 fps single for a 1080p HQ frame, against 318 / 53 fps in its README on a less loaded machine, so these are diff --git a/crates/dnx/src/decode.rs b/crates/dnx/src/decode.rs index 22f9f542b..ede98b0b3 100644 --- a/crates/dnx/src/decode.rs +++ b/crates/dnx/src/decode.rs @@ -54,9 +54,11 @@ pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result { } else { (decode_unit(&h1, &data[..off], opts), decode_unit(&h2, &data[off..], opts)) }; + let (top, bottom) = if h1.field == FieldCode::Field2 { (p2?, p1?) } else { (p1?, p2?) }; + // Both field payloads must be valid before committing the full-frame + // allocation; a damaged header alone must not allocate an output picture. let mut f = Frame::new_like(&h1, h1.width, h1.lines * 2); f.interlace = Interlace::TopFieldFirst; - let (top, bottom) = if h1.field == FieldCode::Field2 { (p2?, p1?) } else { (p1?, p2?) }; top.copy_into(&mut f, 0, 2); bottom.copy_into(&mut f, 1, 2); Ok(f) diff --git a/crates/dnx/src/tables.rs b/crates/dnx/src/tables.rs index c1fe24909..905d808f1 100644 --- a/crates/dnx/src/tables.rs +++ b/crates/dnx/src/tables.rs @@ -226,13 +226,13 @@ pub(crate) fn vlc(i: usize) -> &'static VlcSet { /// Orthonormal 1-D DCT-II basis: `BASIS[u][x] = C(u)/2 · cos((2x+1)uπ/16)`, `C(0) = 1/√2`. pub(crate) const BASIS: [[f32; 8]; 8] = [ [0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39], - [0.490_392_64, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.490_392_64], + [0.49039264, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.49039264], [0.461_939_77, 0.191_341_72, -0.191_341_72, -0.461_939_77, -0.461_939_77, -0.191_341_72, 0.191_341_72, 0.461_939_77], - [0.415_734_8, -0.097_545_16, -0.490_392_64, -0.277_785_12, 0.277_785_12, 0.490_392_64, 0.097_545_16, -0.415_734_8], + [0.415_734_8, -0.097_545_16, -0.49039264, -0.277_785_12, 0.277_785_12, 0.49039264, 0.097_545_16, -0.415_734_8], [0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39, 0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39], - [0.277_785_12, -0.490_392_64, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.490_392_64, -0.277_785_12], + [0.277_785_12, -0.49039264, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.49039264, -0.277_785_12], [0.191_341_72, -0.461_939_77, 0.461_939_77, -0.191_341_72, -0.191_341_72, 0.461_939_77, -0.461_939_77, 0.191_341_72], - [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.490_392_64, 0.490_392_64, -0.415_734_8, 0.277_785_12, -0.097_545_16], + [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.49039264, 0.49039264, -0.415_734_8, 0.277_785_12, -0.097_545_16], ]; #[cfg(test)] diff --git a/crates/dnx/tests/common/mod.rs b/crates/dnx/tests/it/common/mod.rs similarity index 100% rename from crates/dnx/tests/common/mod.rs rename to crates/dnx/tests/it/common/mod.rs diff --git a/crates/dnx/tests/it/fuzz_hostile.rs b/crates/dnx/tests/it/fuzz_hostile.rs new file mode 100644 index 000000000..8ff02e27c --- /dev/null +++ b/crates/dnx/tests/it/fuzz_hostile.rs @@ -0,0 +1,171 @@ +//! Hostile-input robustness: every header byte set to extreme values, every bit flipped, +//! truncation at (nearly) every length and random mutation of encoder-made frames must never +//! panic or run away (errors are fine). No ffmpeg needed. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with, probe}; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +fn seeds() -> Vec> { + let mut v = Vec::new(); + for (profile, depth, w, h) in + [(Profile::Lb, 8, 64u32, 48u32), (Profile::Hq, 8, 48, 32), (Profile::Hqx, 10, 64, 32), (Profile::Hqx, 12, 48, 32), (Profile::R444, 10, 48, 32)] + { + let mut cfg = EncoderConfig::new(profile, w, h); + cfg.bit_depth = depth; + let chroma = cfg.chroma(); + let mut f = Frame::new(w, h, chroma, depth, false); + for (i, s) in f.y.iter_mut().enumerate() { + *s = ((16 + (i * 37) % 200) as u16) << (depth - 8); + } + if chroma != ChromaFormat::Yuv444 { + f.cb.fill(128 << (depth - 8)); + f.cr.fill(100 << (depth - 8)); + } + if let Ok(mut enc) = Encoder::with_config(cfg) + && let Ok(d) = enc.encode(&f) + { + v.push(d); + } + } + v +} + +fn run(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + let res = catch_unwind(AssertUnwindSafe(|| { + let _ = probe(d); + for opts in [DecodeOptions::default(), DecodeOptions { threads: true, spec_dequant: true }] { + let _ = decode_frame_with(d, &opts); + } + })); + assert!(res.is_ok(), "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=1500` runs all of it (see [`Depth`]). +#[test] +fn hostile_frames_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let depth = Depth::from_env(); + let seeds = seeds(); + assert!(!seeds.is_empty()); + for (si, seed) in seeds.iter().enumerate() { + for n in depth.truncations(seed.len()) { + run(&seed[..n], &format!("seed {si} truncate {n}")); + } + // The header (and the start of the scan-line index): every byte to extreme values, every bit. + for p in 0..seed.len().min(0x300) { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } + let mut d = seed.clone(); + d[p] = v; + run(&d, &format!("seed {si} byte {p:#x}={v:#x}")); + } + for b in 0..8 { + if !depth.bit(p, b) { + continue; + } + let mut d = seed.clone(); + d[p] ^= 1 << b; + run(&d, &format!("seed {si} flip {p:#x}.{b}")); + } + } + // Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C). + for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] { + let mut d = seed.clone(); + if let Some(s) = d.get_mut(off..off + 2) { + s.copy_from_slice(&v.to_be_bytes()); + } + run(&d, &format!("seed {si} u16 {off:#x}={v:#x}")); + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64); + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { + 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; + } + } + if depth.round(i) { + run(&d, &format!("seed {si} random {i}")); + } + } + } +} + +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 400 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 1500 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 400, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=1500 runs everything)"); + Depth { full: false, truncations: 400, rounds: 1500, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} diff --git a/crates/dnx/tests/it/main.rs b/crates/dnx/tests/it/main.rs new file mode 100644 index 000000000..c12ebd099 --- /dev/null +++ b/crates/dnx/tests/it/main.rs @@ -0,0 +1,6 @@ +mod common; +mod fuzz_hostile; +mod oracle_decode; +mod oracle_encode; +mod perf; +mod roundtrip; diff --git a/crates/dnx/tests/oracle_decode.rs b/crates/dnx/tests/it/oracle_decode.rs similarity index 99% rename from crates/dnx/tests/oracle_decode.rs rename to crates/dnx/tests/it/oracle_decode.rs index 476ee68d1..a9da675c8 100644 --- a/crates/dnx/tests/oracle_decode.rs +++ b/crates/dnx/tests/it/oracle_decode.rs @@ -1,6 +1,6 @@ //! Decode ffmpeg-made DNxHD / DNxHR fixtures and compare with ffmpeg's own decode. -mod common; +use crate::common; use common::*; use filmcraft_dnx::{DecodeOptions, decode_frame_with}; diff --git a/crates/dnx/tests/oracle_encode.rs b/crates/dnx/tests/it/oracle_encode.rs similarity index 99% rename from crates/dnx/tests/oracle_encode.rs rename to crates/dnx/tests/it/oracle_encode.rs index ca64941df..4d5702698 100644 --- a/crates/dnx/tests/oracle_encode.rs +++ b/crates/dnx/tests/it/oracle_encode.rs @@ -1,6 +1,6 @@ //! Encode DNxHR with our encoder, decode with ffmpeg (`-xerror`) and with our decoder. -mod common; +use crate::common; use common::*; use filmcraft_dnx::{ChromaFormat, Encoder, EncoderConfig, Profile, decode_frame}; diff --git a/crates/dnx/tests/perf.rs b/crates/dnx/tests/it/perf.rs similarity index 95% rename from crates/dnx/tests/perf.rs rename to crates/dnx/tests/it/perf.rs index 255f1961c..befd20a48 100644 --- a/crates/dnx/tests/perf.rs +++ b/crates/dnx/tests/it/perf.rs @@ -1,4 +1,4 @@ -//! Throughput: `cargo test --release -p filmcraft-dnx --test perf -- --ignored --nocapture`. +//! Throughput: `cargo test --release -p filmcraft-dnx --test it perf:: -- --ignored --nocapture`. use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with}; use std::time::Instant; diff --git a/crates/dnx/tests/roundtrip.rs b/crates/dnx/tests/it/roundtrip.rs similarity index 100% rename from crates/dnx/tests/roundtrip.rs rename to crates/dnx/tests/it/roundtrip.rs diff --git a/crates/dnx/tools/extract_tables.py b/crates/dnx/tools/extract_tables.py index 56d73a6e1..2e120cc93 100644 --- a/crates/dnx/tools/extract_tables.py +++ b/crates/dnx/tools/extract_tables.py @@ -14,8 +14,18 @@ prefix code. """ import re import sys +from pathlib import Path -text = open(sys.argv[1], encoding="utf-8").read() + +def _spec_path(arg): + """Resolve a CLI path argument and require it to be an existing regular file.""" + path = Path(arg).resolve() + if not path.is_file(): + sys.exit(f"not a readable file: {arg}") + return path + + +text = open(_spec_path(sys.argv[1]), encoding="utf-8").read() lines = text.split("\n") diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 088163f10..d39450794 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -42,6 +42,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } craft-docpath = { workspace = true } web-time = "1" diff --git a/crates/engine/src/aaf_omf_tests.rs b/crates/engine/src/aaf_omf_tests.rs index 83bdd05de..b80245507 100644 --- a/crates/engine/src/aaf_omf_tests.rs +++ b/crates/engine/src/aaf_omf_tests.rs @@ -87,6 +87,50 @@ fn aaf_embedded_trimmed_audio_round_trips_sample_exact() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn embedded_aaf_and_omf_import_leave_existing_media_files_intact() { + for (format, extension) in [(filmcraft_interchange::Format::Aaf, "aaf"), (filmcraft_interchange::Format::Omf, "omf")] { + let (mut s, _items, seq, dir) = setup(&format!("embedded-collision-{extension}")); + let path = dir.join(format!("cut.{extension}")); + let path_text = path.to_string_lossy().into_owned(); + let command = if format == filmcraft_interchange::Format::Aaf { "file.exportAaf" } else { "file.exportOmf" }; + s.execute(command, json!({"path": path_text, "audio": "embedded", "sequence": seq.0})).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let opts = filmcraft_interchange::ImportOptions { base_dir: Some(dir.to_string_lossy().into_owned()), name: Some("cut".into()), ..Default::default() }; + let (_, extracted, _) = if format == filmcraft_interchange::Format::Aaf { + filmcraft_interchange::aaf::import(&bytes, &opts).unwrap() + } else { + filmcraft_interchange::omf::import(&bytes, &opts).unwrap() + }; + let first = extracted.first().expect("embedded audio"); + let original = b"ORIGINAL USER DATA"; + std::fs::create_dir_all(std::path::Path::new(&first.path).parent().unwrap()).unwrap(); + std::fs::write(&first.path, original).unwrap(); + + let response = s.execute("file.import", json!({"paths": [path_text]})).unwrap(); + assert!(std::fs::read(&first.path).unwrap().as_slice() == original, "{extension}: existing file was overwritten"); + let imported = s.project.sequence(imported_seq(&response)).unwrap(); + let new_path = path_of(&s, imported.audio_tracks[0].items[0].item); + assert_ne!(new_path, first.path, "{extension}: imported audio still points at the existing file"); + assert!(new_path.ends_with(" (2).wav"), "{extension}: {new_path}"); + assert!(std::fs::read(&new_path).unwrap() == first.wav, "{extension}: imported audio was not extracted to the new path"); + #[cfg(unix)] + { + let protected = dir.join("protected.wav"); + std::fs::write(&protected, original).unwrap(); + std::fs::remove_file(&first.path).unwrap(); + std::os::unix::fs::symlink(&protected, &first.path).unwrap(); + let again = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert!(std::fs::read(&protected).unwrap().as_slice() == original, "{extension}: symlink target was overwritten"); + let imported_again = s.project.sequence(imported_seq(&again)).unwrap(); + let third_path = path_of(&s, imported_again.audio_tracks[0].items[0].item); + assert!(third_path.ends_with(" (3).wav"), "{extension}: {third_path}"); + assert!(std::fs::read(&third_path).unwrap() == first.wav, "{extension}: the third extraction is not the embedded audio"); + } + let _ = std::fs::remove_dir_all(dir); + } +} + #[test] fn aaf_separate_aiff_breakout_and_linked() { let (mut s, items, seq, dir) = setup("aaf-separate"); diff --git a/crates/engine/src/captions.rs b/crates/engine/src/captions.rs index d048a5677..02bddd456 100644 --- a/crates/engine/src/captions.rs +++ b/crates/engine/src/captions.rs @@ -82,7 +82,11 @@ fn parse_color(v: &Value) -> Option<[u8; 4]> { _ => None, }; } - let h = v.as_str()?.trim_start_matches('#'); + let h = v.as_str()?; + let h = h.strip_prefix('#').unwrap_or(h); + if !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } let b = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); match h.len() { 6 => Some([b(0)?, b(2)?, b(4)?, 255]), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 162fb18ca..5c564e69e 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -3077,6 +3077,13 @@ fn build() -> Vec { let (keyframes, v) = { let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?; let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?; + // the parameter's hard range, which `effects.list` publishes: a value outside it + // (a factor where a percentage is meant, #2679) is refused, not saved + if let (Some(filmcraft_project::ParamKind::Float { min, max, .. }), ParamValue::Float(x)) = (def.map(|d| &d.kind), &v) + && !(*min..=*max).contains(x) + { + return Err(bad("effects.setParam", format!("`{pid}` must be between {min} and {max}, got {x}"))); + } let keyframes = if keyframe { // the stopwatch and the value in one step prm.put_keyframe(mt, v.clone()); diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index c60fc0f53..18fdf3cca 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -220,5 +220,7 @@ fn build_demo(pool: &MediaPool) -> Option<(Project, ItemId)> { for tr in s.all_tracks_mut() { tr.sort(); } + // the transitions and markers above take fixed ids: new ones start past them + p.next_id = p.next_id.max(p.max_id().saturating_add(1)); Some((p, seq)) } diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs index 7168b181a..942d8f43c 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -39,15 +39,27 @@ impl std::error::Error for NotFollowed {} /// where it was opened from. pub fn resolve(stored: &str, document: Option<&Path>) -> Result { let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf())); - let path: PathBuf = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; - // A path that is still relative would be taken from the working directory, which the - // document does not choose. - if path.is_relative() { - return Err(NotFollowed::NoDocumentFolder); - } + let path: PathBuf = match craft_docpath::resolve_absolute(stored, document.as_deref()) { + Ok(path) => path, + // Without a document, a relative path resolves but stays relative. + Err(Refused::Invalid) if document.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| !foreign_root(&p)) => { + return Err(NotFollowed::NoDocumentFolder); + } + Err(r) => return Err(NotFollowed::Refused(r)), + }; path.into_os_string().into_string().map_err(|_| NotFollowed::Refused(Refused::Invalid)) } +/// Whether an unresolved `craft_docpath::resolve` result starts with a drive (`C:`) or a share +/// root (`\\`) outside Windows. Such a path names no file here, and no document folder makes it one. +fn foreign_root(path: &Path) -> bool { + if cfg!(windows) { + return false; + } + let b = path.as_os_str().as_encoded_bytes(); + b.starts_with(br"\\") || matches!((b.first(), b.get(1)), (Some(l), Some(b':')) if l.is_ascii_alphabetic()) +} + #[cfg(test)] mod tests { use super::*; @@ -60,5 +72,27 @@ mod tests { assert_eq!(resolve("clip.mp4", None), Err(NotFollowed::NoDocumentFolder)); assert_eq!(resolve(r"\\192.0.2.1\share\x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); assert_eq!(resolve("file://192.0.2.1/s/x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("jar:take.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Url))); + // Colons in names: an alternate data stream or a URL on Windows, a plain character elsewhere. + if cfg!(windows) { + assert_eq!(resolve("media/take:2.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); + assert_eq!(resolve("take:2.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Url))); + } else { + assert_eq!(resolve("media/take:2.mp4", Some(&doc)), Ok(dir.join("media").join("take:2.mp4").to_string_lossy().into_owned())); + assert_eq!(resolve("take:2.mp4", Some(&doc)), Ok(dir.join("take:2.mp4").to_string_lossy().into_owned())); + } + } + + /// Without a document a relative path waits for the document's folder; outside Windows a + /// drive path names no file and is refused. + #[test] + fn foreign_drive_paths_without_a_document_are_refused() { + for stored in ["clip.mp4", "../clip.mp4"] { + assert_eq!(resolve(stored, None), Err(NotFollowed::NoDocumentFolder), "{stored}"); + } + #[cfg(unix)] + for stored in [r"C:\x", r"C:x", "file:///C:/x/clip.mp4"] { + assert_eq!(resolve(stored, None), Err(NotFollowed::Refused(Refused::Invalid)), "{stored}"); + } } } diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 79ddcb810..57a07791c 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -9,6 +9,16 @@ use serde_json::{Value, json}; use crate::Session; +#[test] +fn project_names_become_one_export_filename_component() { + for name in ["../../outside", "/absolute/path", r"C:\\outside", ".."] { + let safe = crate::export_tools::file_safe(name); + assert!(!safe.contains(['/', '\\']), "{name:?} became {safe:?}"); + assert_ne!(safe, "."); + assert_ne!(safe, ".."); + } +} + #[test] fn hostile_export_ranges_are_rejected_without_panicking() { let mut session = demo(); @@ -145,11 +155,13 @@ fn files_in(dir: &Path) -> Vec { v } -/// Every built-in preset: a quarter second of the demo sequence (seven frames at 23.976) exported +/// Every built-in preset: 1.0 to 1.08 s of the demo sequence (three frames at 23.976) exported /// with `file.exportMedia {preset}`; ffprobe checks container, codec, profile, frame size, frame -/// rate and audio format, and the data rate where the codec fixes it. +/// rate and audio format, and the data rate where the codec fixes it. Three frames are enough for +/// all of these, and each extra frame costs a 4K H.264 encode in five presets. #[test] fn every_builtin_preset_exports_and_ffprobe_confirms() { + let (start, end) = (1.0, 1.08); let mut s = demo(); let presets = s.execute("export.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().clone(); assert!(presets.len() >= 24); @@ -159,7 +171,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { let sub = dir.0.join(format!("p{}", filmcraft_export::presets::preset_key(name))); std::fs::create_dir_all(&sub).unwrap(); let path = sub.join("Demo").to_string_lossy().to_string(); - let params = json!({"preset": name, "path": path, "range": "custom", "startSeconds": 1.0, "endSeconds": 1.25, "wait": true}); + let params = json!({"preset": name, "path": path, "range": "custom", "startSeconds": start, "endSeconds": end, "wait": true}); let frames = s.execute("export.resolve", params.clone()).unwrap()["frames"].as_u64().unwrap(); let r = s.execute("file.exportMedia", params).unwrap_or_else(|e| panic!("{name}: {e}")); let out = r["path"].as_str().unwrap().to_string(); @@ -172,7 +184,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx" | "exr") { let files = files_in(&sub); let ext = Path::new(&out).extension().unwrap().to_string_lossy().to_string(); - assert_eq!(frames, 7, "frames 23..=29 at 23.976 fps"); + assert_eq!(frames, 3, "frames 23..=25 at 23.976 fps"); let want: Vec = (0..frames).map(|i| format!("Demo{i:03}.{ext}")).collect(); assert_eq!(files, want, "{name}"); if let Some(j) = probe(&sub.join(&want[0]).to_string_lossy()) { @@ -208,7 +220,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert_eq!(a["codec_name"], codec, "{name}"); assert_eq!(a["sample_rate"], "48000", "{name}"); assert_eq!(a["channels"], 2, "{name}"); - assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]); + assert!((num(&j["format"]["duration"]) - (end - start)).abs() < 0.01, "{name}: {}", j["format"]["duration"]); continue; } "aac" => { @@ -218,7 +230,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}"); let a = stream(&j, "audio").unwrap(); assert_eq!((a["codec_name"].as_str(), a["sample_rate"].as_str(), a["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)), "{name}"); - assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.03, "{name}: {}", j["format"]["duration"]); + assert!((num(&j["format"]["duration"]) - (end - start)).abs() < 0.03, "{name}: {}", j["format"]["duration"]); continue; } "gif" => { @@ -322,9 +334,6 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { } } -/// H.264 bitrate control over three seconds of the demo: the adaptive Match Source preset -/// targets width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's -/// measured video bitrate stays within the VBR maximum and close to the target. #[test] fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() { let mut s = demo(); @@ -344,6 +353,12 @@ fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() { assert_eq!(&std::fs::read(&m4a).unwrap()[4..8], b"ftyp"); } +/// H.264 bitrate control over one second of the demo: the adaptive Match Source preset targets +/// width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's measured +/// video bitrate stays within the VBR maximum and close to the target. 0.5 to 1.5 s holds the +/// second half of the one-second fade in from black, where every frame changes and each preset +/// spends its whole budget; the shot after it is simple enough that both 1080p presets land at the +/// same bitrate, far under their targets. #[test] fn h264_presets_hit_their_bitrates() { let mut s = demo(); @@ -356,7 +371,7 @@ fn h264_presets_hit_their_bitrates() { let path = dir.path(&format!("{}.mp4", filmcraft_export::presets::preset_key(preset))); s.execute( "file.exportMedia", - json!({"preset": preset, "path": path, "range": "custom", "startSeconds": 0.0, "endSeconds": 3.0, "audio": false, "wait": true}), + json!({"preset": preset, "path": path, "range": "custom", "startSeconds": 0.5, "endSeconds": 1.5, "audio": false, "wait": true}), ) .unwrap(); let Some(j) = probe(&path) else { return }; diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 98c7ad2fc..170bc142b 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -385,10 +385,11 @@ pub fn default_export_dir(s: &Session) -> PathBuf { crate::temp_dir() } -fn file_safe(n: &str) -> String { +/// Convert project-derived display text into exactly one safe output filename component. +pub fn file_safe(n: &str) -> String { let s: String = n.chars().map(|c| if c.is_alphanumeric() || " -_().".contains(c) { c } else { '_' }).collect(); let s = s.trim().to_string(); - if s.is_empty() { "Export".into() } else { s } + if s.is_empty() || matches!(s.as_str(), "." | "..") { "Export".into() } else { s } } /// `path` with the settings' extension (added when missing; image sequences keep theirs). diff --git a/crates/engine/src/graphic_templates.rs b/crates/engine/src/graphic_templates.rs index 00b66c72f..01fa146a4 100644 --- a/crates/engine/src/graphic_templates.rs +++ b/crates/engine/src/graphic_templates.rs @@ -44,6 +44,7 @@ fn query(id: &'static str, label: &'static str, params: &'static str, run: Run) } const G: &[&str] = &["Graphics and Titles"]; +const MAX_THUMBNAIL_DIMENSION: u32 = 1920; fn has_graphic(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; @@ -149,8 +150,7 @@ fn entry_json(e: &LibraryEntry) -> Value { /// Render a template's thumbnail (static: rolls are shown at rest) as straight sRGB RGBA8, /// `w` pixels wide with the template's aspect ratio. Returns (width, height, pixels). pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec) { - let w = w.clamp(16, 1920); - let h = ((w as f64 * t.canvas[1] as f64 / t.canvas[0].max(1) as f64).round() as u32).max(1); + let (w, h) = thumbnail_dimensions(t.canvas, w); register_fonts(t); let mut meta = t.graphic.clone(); meta.roll.mode = RollMode::Off; @@ -171,11 +171,25 @@ pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec) { // a moment into the clip, so intro animations have settled a little let at = Tick((t.duration.0 / 2).min(filmcraft_time::TICKS_PER_SECOND)); let mut img = filmcraft_render::image::Image::new(w as usize, h as usize); - let k = w as f64 / t.canvas[0].max(1) as f64; + // fit both sides: a capped height leaves `w / canvas width` too large for a tall canvas + let k = (w as f64 / t.canvas[0].max(1) as f64).min(h as f64 / t.canvas[1].max(1) as f64); filmcraft_render::graphic_clip::render_graphic(&it, at, (t.canvas[0], t.canvas[1]), &Affine::scale(k, k), &mut img); (w, h, img.to_rgba8()) } +/// Fit a requested thumbnail width within a bounded rectangle without trusting the template's +/// aspect ratio to derive an unbounded second dimension. +fn thumbnail_dimensions(canvas: [u32; 2], requested_width: u32) -> (u32, u32) { + let mut w = requested_width.clamp(16, MAX_THUMBNAIL_DIMENSION); + let aspect = canvas[0].max(1) as f64 / canvas[1].max(1) as f64; + let mut h = (w as f64 / aspect).round().clamp(1.0, u32::MAX as f64) as u32; + if h > MAX_THUMBNAIL_DIMENSION { + h = MAX_THUMBNAIL_DIMENSION; + w = (h as f64 * aspect).round().clamp(1.0, MAX_THUMBNAIL_DIMENSION as f64) as u32; + } + (w, h) +} + /// Cached thumbnails by template id + content + width (the UI asks every frame). pub fn thumbnail_cached(t: &GraphicsTemplate, w: u32) -> Arc<(u32, u32, Vec)> { static CACHE: OnceLock)>>>> = OnceLock::new(); diff --git a/crates/engine/src/graphic_templates_tests.rs b/crates/engine/src/graphic_templates_tests.rs index e49c0ec8f..47a7c1528 100644 --- a/crates/engine/src/graphic_templates_tests.rs +++ b/crates/engine/src/graphic_templates_tests.rs @@ -143,6 +143,13 @@ fn builtin_templates_apply_and_thumbnail() { } } +#[test] +fn template_thumbnail_dimensions_fit_a_bounded_rectangle() { + assert_eq!(super::thumbnail_dimensions([1920, 1080], 160), (160, 90)); + assert_eq!(super::thumbnail_dimensions([1, 16_384], 1920), (1, 1920)); + assert_eq!(super::thumbnail_dimensions([16_384, 1], 1920), (1920, 1)); +} + #[test] fn responsive_time_protects_intro_and_outro_when_trimmed() { let mut s = demo("gt-time"); diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs index 5102308c8..d32713fb6 100644 --- a/crates/engine/src/interchange.rs +++ b/crates/engine/src/interchange.rs @@ -1,5 +1,7 @@ //! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO, AAF, OMF) ↔ the session's project. +use std::path::Path; + use serde_json::{Value, json}; use filmcraft_interchange::{ExportOptions, Format, ImportOptions}; @@ -24,6 +26,32 @@ fn is_file_media(item: &filmcraft_project::ProjectItem) -> bool { matches!(&item.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::File { .. })) } +/// Write embedded audio without replacing an occupied path; return the path used. +fn write_embedded_audio(s: &Session, path: &str, wav: &[u8]) -> Result { + let original = Path::new(path); + let stem = original.file_stem().ok_or_else(|| EngineError::Other(format!("{path}: missing audio file name")))?; + for number in 1..=1_000 { + let candidate = if number == 1 { + original.to_path_buf() + } else { + let mut name = stem.to_os_string(); + name.push(format!(" ({number})")); + if let Some(extension) = original.extension() { + name.push("."); + name.push(extension); + } + original.with_file_name(name) + }; + let candidate_text = candidate.to_string_lossy(); + match s.services.write_file_new(&candidate_text, wav) { + Ok(()) => return Ok(candidate_text.into_owned()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(EngineError::Other(format!("{candidate_text}: {e}"))), + } + } + Err(EngineError::Other(format!("{path}: no unused embedded-audio filename after 1000 attempts"))) +} + /// Import a document: merge its bins, media and sequences into the project (one undo step), then /// link each media file that exists on disk. Returns the new sequences. pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result { @@ -37,12 +65,17 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu // AAF / OMF may embed audio: write it next to the document (the media items point there) Format::Aaf | Format::Omf => { let r = if format == Format::Aaf { filmcraft_interchange::aaf::import(bytes, &opts) } else { filmcraft_interchange::omf::import(bytes, &opts) }; - let (fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let (mut fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?; for m in extracted { - if let Some(dir) = std::path::Path::new(&m.path).parent().filter(|_| !s.services.export_in_memory()) { - let _ = std::fs::create_dir_all(dir); + let media = fragment + .project + .item_mut(m.item) + .and_then(|item| item.as_media_mut()) + .ok_or_else(|| EngineError::Other(format!("{}: embedded audio has no media item", m.path)))?; + let written_path = write_embedded_audio(s, &m.path, &m.wav)?; + if written_path != m.path { + media.media = MediaRef::File { path: written_path }; } - s.services.write_file(&m.path, &m.wav).map_err(|e| EngineError::Other(format!("{}: {e}", m.path)))?; } (fragment, report) } @@ -152,3 +185,51 @@ pub fn export(s: &mut Session, p: &Value) -> Result { s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(e.to_string()))?; Ok(json!({"path": path, "bytes": bytes.len(), "report": report_json(&report)})) } + +#[cfg(test)] +mod embedded_audio_tests { + use std::collections::HashMap; + use std::io; + use std::sync::{Arc, Mutex}; + + use crate::{Services, Session}; + + use super::write_embedded_audio; + + #[derive(Default)] + struct VirtualFiles(Mutex>>); + + impl Services for VirtualFiles { + fn read_file(&self, path: &str) -> io::Result> { + self.0.lock().unwrap().get(path).cloned().ok_or_else(|| io::ErrorKind::NotFound.into()) + } + + fn write_file(&self, path: &str, data: &[u8]) -> io::Result<()> { + self.0.lock().unwrap().insert(path.to_owned(), data.to_vec()); + Ok(()) + } + + fn write_file_new(&self, path: &str, data: &[u8]) -> io::Result<()> { + let mut files = self.0.lock().unwrap(); + if files.contains_key(path) { + return Err(io::ErrorKind::AlreadyExists.into()); + } + files.insert(path.to_owned(), data.to_vec()); + Ok(()) + } + } + + #[test] + fn virtual_extraction_keeps_existing_media() { + let files = Arc::new(VirtualFiles::default()); + files.0.lock().unwrap().insert("/files/cut Audio.wav".into(), b"first".to_vec()); + files.0.lock().unwrap().insert("/files/cut Audio (2).wav".into(), b"second".to_vec()); + let session = Session::new(files.clone()); + let path = write_embedded_audio(&session, "/files/cut Audio.wav", b"new audio").unwrap(); + assert_eq!(path, "/files/cut Audio (3).wav"); + let stored = files.0.lock().unwrap(); + assert_eq!(stored["/files/cut Audio.wav"], b"first"); + assert_eq!(stored["/files/cut Audio (2).wav"], b"second"); + assert_eq!(stored["/files/cut Audio (3).wav"], b"new audio"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5d0a044d0..47a9cfd6e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -102,6 +102,14 @@ pub type Result = std::result::Result; pub trait Services: Send + Sync { fn read_file(&self, path: &str) -> std::io::Result>; fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>; + /// Publish a complete new file only when the final path is unoccupied. Publication must + /// reject a concurrent collision; a prior existence check alone is insufficient. + /// Return `ErrorKind::AlreadyExists` for an occupied final path so the importer can retry + /// with a numbered name; return other errors without publishing a partial file. + /// Hosts without this operation fail closed. + fn write_file_new(&self, _path: &str, _data: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "exclusive file creation is unavailable on this host")) + } /// Size of a file in bytes (an error when it is missing). Hosts should override this: the /// default reads the whole file. fn file_size(&self, path: &str) -> std::io::Result { @@ -159,6 +167,23 @@ impl Services for FsServices { // Atomic + durable: temp file in the same directory, fsync, rename, fsync the directory. filmcraft_format::atomic_write(std::path::Path::new(path), data) } + fn write_file_new(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + let path = std::path::Path::new(path); + let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| std::path::Path::new(".")); + std::fs::create_dir_all(dir).map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + std::io::Error::new(std::io::ErrorKind::NotADirectory, format!("{} is not a directory: {error}", dir.display())) + } else { + error + } + })?; + match std::fs::symlink_metadata(path) { + Ok(_) => return Err(std::io::ErrorKind::AlreadyExists.into()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + filmcraft_format::atomic_write_new(path, data) + } fn file_size(&self, path: &str) -> std::io::Result { let m = std::fs::metadata(path)?; if m.is_file() { Ok(m.len()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("{path} is not a file"))) } diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index d1e65f815..f2ea3013f 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -636,22 +636,33 @@ fn format_size(b: u64) -> String { } } -/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds. +/// Unix seconds → (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`, so `days` +/// (≤ `u64::MAX / 86_400`) always fits an `i64`. +fn utc_parts(secs: u64) -> (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, 0)); + 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 + } +} + +/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds. Years far outside chrono's own range are preserved +/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn format_date(secs: u64) -> String { - let days = (secs / 86_400) as i64; - let rem = secs % 86_400; - // civil-from-days (Howard Hinnant's algorithm) - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - 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 = if m <= 2 { y + 1 } else { y }; - format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem / 60) % 60) + let (y, t) = utc_parts(secs); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%d %H:%M")) } fn probe_cmd(s: &mut Session, p: &Value) -> Result { @@ -773,3 +784,23 @@ pub fn std_home_dir() -> Option { let vars = if cfg!(windows) { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] }; vars.iter().find_map(|v| std::env::var(v).ok().filter(|h| !h.is_empty())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_date_is_utc_minutes() { + // Pinned against the previous hand-rolled civil-from-days output. + assert_eq!(format_date(0), "1970-01-01 00:00"); + assert_eq!(format_date(951_782_400), "2000-02-29 00:00"); + assert_eq!(format_date(1_709_164_800), "2024-02-29 00:00"); + assert_eq!(format_date(1_790_775_907), "2026-09-30 13:45"); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(format_date(253_402_300_800), "10000-01-01 00:00"); + // Values far beyond chrono's own range are preserved (the old hand-rolled civil-from-days + // kept them); they must not collapse to the epoch (reviewer FredApps). + assert_eq!(format_date(10_000_000_000_000), "318857-05-20 17:46"); + assert_eq!(format_date(u64::MAX), "584554051223-11-09 07:00"); + } +} diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs index 3a78fff18..53c543e80 100644 --- a/crates/engine/src/project_manager.rs +++ b/crates/engine/src/project_manager.rs @@ -137,7 +137,10 @@ pub fn run(s: &mut Session, p: &Value) -> Result { for id in ids { let Some(m) = proj.item(id).and_then(|i| i.as_media()).cloned() else { continue }; let MediaRef::File { path } = &m.media else { continue }; - let size = s.services.file_size(path).unwrap_or(0); + // Project files carry stored paths; use only the media pool's resolved, policy-approved + // pathname for filesystem I/O. Keep the stored path in the project itself. + let source_path = s.media.follow(path).map_err(|why| EngineError::Other(format!("{path} is not opened: {why}")))?; + let size = s.services.file_size(&source_path).unwrap_or(0); original_bytes += size; let file = Path::new(path); let stem = file.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| format!("media-{}", id.0)); @@ -158,20 +161,23 @@ pub fn run(s: &mut Session, p: &Value) -> Result { }; taken.push(out.clone()); let proxy = match (&m.proxy, include_proxies) { - (Some(MediaRef::File { path: pp }), true) if s.services.file_size(pp).is_ok() => { - let pf = Path::new(pp); - let ps = pf.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let pe = pf.extension().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let o = unique_path(&dest_dir.join("Proxies"), &ps, "", &pe, &taken).to_string_lossy().into_owned(); - taken.push(o.clone()); - let b = s.services.file_size(pp).unwrap_or(0); - original_bytes += b; - result_bytes += b; - Some((pp.clone(), o)) - } + (Some(MediaRef::File { path: pp }), true) => match s.media.follow(pp) { + Ok(resolved_proxy) if s.services.file_size(&resolved_proxy).is_ok() => { + let pf = Path::new(&resolved_proxy); + let ps = pf.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let pe = pf.extension().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let o = unique_path(&dest_dir.join("Proxies"), &ps, "", &pe, &taken).to_string_lossy().into_owned(); + taken.push(o.clone()); + let b = s.services.file_size(&resolved_proxy).unwrap_or(0); + original_bytes += b; + result_bytes += b; + Some((resolved_proxy, o)) + } + _ => None, + }, _ => None, }; - work.push(Work { item: id, src_path: path.clone(), out, range, proxy }); + work.push(Work { item: id, src_path: source_path, out, range, proxy }); } let previews = s.path.as_deref().map(crate::previews::dir_for_project).filter(|d| include_previews && d.is_dir()); if let Some(d) = &previews { diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index c5f967a75..136229370 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -1076,12 +1076,10 @@ fn project_settings_scratch(s: &mut Session, p: &Value) -> Result { None => {} Some(Value::Null) => *slot = None, Some(Value::String(x)) if x.is_empty() || x.eq_ignore_ascii_case("same as project") => *slot = None, - Some(Value::String(x)) - if matches!(crate::doc_path::resolve(x, s.path.as_deref().map(std::path::Path::new)), Err(crate::doc_path::NotFollowed::Refused(_))) => - { - return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` must be a folder on this computer, not a network share or device"))); - } Some(Value::String(x)) => { + if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(x, s.path.as_deref().map(std::path::Path::new)) { + return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` must be a folder on this computer: {why}"))); + } chosen.push(x.clone()); *slot = Some(x.clone()); } diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index 075525654..64965166c 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -307,6 +307,11 @@ fn media_properties_settings_and_scratch_disks() { assert!(s.execute("file.projectSettings.general", json!({"renderer": "quantum"})).is_err()); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.settings.renderer, crate::project_tools::RENDERER_GPU); + // Scratch Disks: a drive another system names is refused before the project has a folder + if cfg!(unix) { + assert!(s.execute("file.projectSettings.scratchDisks", json!({"captured": r"D:\Captured"})).is_err()); + assert!(s.project.settings.scratch.captured.is_none()); + } // Scratch Disks: previews follow the Video Previews folder once the project is saved let proj = dir.join("Show.fcproj").to_string_lossy().to_string(); s.execute("file.saveAs", json!({"path": proj})).unwrap(); diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index d6c720fa6..68204d2af 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -96,6 +96,19 @@ pub fn transcode( out: &str, preset: &Preset, progress: &Progress, +) -> std::result::Result { + transcode_with_file(clip, name, src, range, out, None, preset, progress) +} + +fn transcode_with_file( + clip: &MediaClip, + name: &str, + src: SharedSource, + range: Option, + out: &str, + output_file: Option>, + preset: &Preset, + progress: &Progress, ) -> std::result::Result { let info = &clip.info; let rate = clip.frame_rate(); @@ -140,6 +153,7 @@ pub fn transcode( as u32, part_of_batch: true, prores_profile: preset.prores.to_string(), + output_file, ..Default::default() }; let project = Arc::new(p); @@ -147,6 +161,59 @@ pub fn transcode( filmcraft_export::export(&project, seq, &settings, &provider, progress).map_err(|e| e.to_string()) } +/// An output created exclusively while choosing its name. Unless kept after a successful +/// transcode, dropping it removes only that file (including on a cancelled or rejected job). +struct ReservedOutput { + path: PathBuf, + file: Option>, + keep: bool, +} + +impl ReservedOutput { + fn path(&self) -> &Path { + &self.path + } + + fn file(&self) -> Option> { + self.file.clone() + } + + fn preserve(&mut self) { + self.keep = true; + } +} + +impl Drop for ReservedOutput { + fn drop(&mut self) { + if !self.keep { + // Release our handle before removal, which is required on Windows. + self.file.take(); + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// Reserve a numbered output name without following a later replacement of its pathname. +fn reserve_unique_path(dir: &Path, stem: &str, suffix: &str, ext: &str, taken: &[String]) -> Result { + std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let mut n = 1; + loop { + let name = if n == 1 { format!("{stem}{suffix}.{ext}") } else { format!("{stem}{suffix} {n}.{ext}") }; + let path = dir.join(name); + if taken.contains(&path.to_string_lossy().into_owned()) { + n += 1; + continue; + } + match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => { + return Ok(ReservedOutput { path, file: Some(Arc::new(file)), keep: false }); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", path.display()))), + } + } +} + /// Frames a transcode of `clip` will write (for progress totals). pub fn frame_count(clip: &MediaClip, range: Option) -> u64 { let rate = clip.frame_rate(); @@ -184,17 +251,17 @@ fn items_param(s: &Session, p: &Value) -> Vec { } /// Start a background transcode job over `work` (item, output path, preset). Returns the job id. -pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'static Preset)>, on_done: OnDone, wait: bool) -> Result { +fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, ReservedOutput, &'static Preset)>, on_done: OnDone, wait: bool) -> Result { let mut tasks = Vec::new(); let mut total = 0; - for (item, out, pr) in work { + for (item, output, pr) in work { let (clip, _, name) = media_path(&s.project, item).ok_or_else(|| bad("media.createProxies", format!("item {} is not file media", item.0)))?; let src = s.media.full_res_source(&s.project, item, &*s.services).ok_or_else(|| EngineError::Other(format!("{name}: no source")))?; if s.media.offline_status(item).is_some() { return Err(EngineError::Other(format!("{name} is offline; link it first"))); } total += frame_count(&clip, None); - tasks.push((item, clip, name, src, out, pr)); + tasks.push((item, clip, name, src, output, pr)); } let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1; let job = crate::Job { id, label, progress: Default::default(), result: Default::default() }; @@ -206,22 +273,20 @@ pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'st let mut bytes = 0; let mut err = None; let n = tasks.len(); - for (k, (item, clip, name, src, out, pr)) in tasks.into_iter().enumerate() { + for (k, (item, clip, name, src, mut output, pr)) in tasks.into_iter().enumerate() { if prog.cancel.load(std::sync::atomic::Ordering::Relaxed) { err = Some("cancelled".to_string()); break; } *prog.status.lock().unwrap_or_else(|e| e.into_inner()) = format!("{name} ({} of {n})", k + 1); - if let Some(d) = Path::new(&out).parent() { - let _ = std::fs::create_dir_all(d); - } - match transcode(&clip, &name, src, None, &out, pr, &prog) { + let out = output.path().to_string_lossy().into_owned(); + match transcode_with_file(&clip, &name, src, None, &out, output.file(), pr, &prog) { Ok(r) => { bytes += r.bytes; + output.preserve(); outs.lock().unwrap_or_else(|e| e.into_inner()).push((item, out)); } Err(e) => { - let _ = std::fs::remove_file(&out); err = Some(format!("{name}: {e}")); break; } @@ -371,14 +436,15 @@ pub fn create(s: &mut Session, p: &Value) -> Result { } }; let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name); - let out = unique_path(&proxy_dir(&path, dest.as_deref()), &stem, "_Proxy", pr.extension, &taken).to_string_lossy().into_owned(); + let output = reserve_unique_path(&proxy_dir(&path, dest.as_deref()), &stem, "_Proxy", pr.extension, &taken)?; + let out = output.path().to_string_lossy().into_owned(); taken.push(out.clone()); - work.push((i, out, pr)); + work.push((i, output, pr)); } if work.is_empty() { return Err(EngineError::Other(format!("nothing to proxy: {}", skipped.iter().filter_map(|v| v["reason"].as_str()).collect::>().join("; ")))); } - let outputs: Vec = work.iter().map(|(i, o, _)| json!({"item": i.0, "path": o})).collect(); + let outputs: Vec = work.iter().map(|(i, o, _)| json!({"item": i.0, "path": o.path()})).collect(); let n = work.len(); let label = format!("Create Proxies ({n} clip{})", if n == 1 { "" } else { "s" }); let attach = bool_p(p, "attach").unwrap_or(true); @@ -481,9 +547,10 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { _ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"), }; let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name); - let out = unique_path(&dir, &stem, "", pr.extension, &taken).to_string_lossy().into_owned(); + let output = reserve_unique_path(&dir, &stem, "", pr.extension, &taken)?; + let out = output.path().to_string_lossy().into_owned(); taken.push(out.clone()); - work.push((i, out, pr)); + work.push((i, output, pr)); } if !work.is_empty() { let id = start_job(s, format!("Ingest: transcode {} clip(s)", work.len()), work, OnDone::ReplaceMedia, false)?; diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index c3b6b3879..7a75826e9 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -489,9 +489,14 @@ pub fn index_folder(root: &Path, limit: usize) -> BTreeMap> if name.starts_with('.') { continue; } - if p.is_dir() { + let Ok(file_type) = e.file_type() else { continue }; + // A relink search must stay within the folder explicitly chosen by the user. In + // particular, following a directory symlink can make a small loop defeat the file + // budget and walk an unrelated tree. Symlinked files are still candidates. + let is_file = if file_type.is_symlink() { std::fs::metadata(&p).is_ok_and(|m| m.is_file()) } else { file_type.is_file() }; + if file_type.is_dir() { queue.push_back(p); - } else { + } else if is_file { out.entry(name.to_ascii_lowercase()).or_default().push(p); n += 1; if n >= limit { @@ -564,7 +569,11 @@ pub fn make_offline(s: &mut Session, p: &Value) -> Result { let mut deleted = Vec::new(); if delete { for (_, path) in &files { - if std::fs::remove_file(path).is_ok() { + // Stored paths in an opened document are not authority to delete arbitrary files. + // Delete only the resolved path that the media pool permits opening. + if let Ok(path) = s.media.follow(path) + && std::fs::remove_file(&path).is_ok() + { deleted.push(path.clone()); } } @@ -583,14 +592,14 @@ fn status_json(s: &Session, id: ItemId) -> Option { let status = match &path { None => "generated", Some(_) if m.offline => "offline", - Some(p) if s.services.file_size(p).is_err() => "missing", + Some(p) if s.media.follow(p).ok().is_none_or(|file| s.services.file_size(&file).is_err()) => "missing", Some(_) => match s.media.offline_status(id) { Some(o) if o.reason == filmcraft_render::offline::OfflineReason::Unreadable => "unreadable", _ => "online", }, }; let proxy = match &m.proxy { - Some(MediaRef::File { path }) => json!({"path": path, "online": s.services.file_size(path).is_ok()}), + Some(MediaRef::File { path }) => json!({"path": path, "online": s.media.follow(path).is_ok_and(|file| s.services.file_size(&file).is_ok())}), _ => Value::Null, }; Some(json!({ diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index 33208c544..180629a2a 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -9,7 +9,7 @@ use serde_json::json; use crate::Session; use crate::media_test_util::{frame_rgba, make_movie, psnr, session_with, tmp_dir}; -use crate::relink::{apply_remap, derive_remap}; +use crate::relink::{apply_remap, derive_remap, index_folder}; const W: u32 = 160; const H: u32 = 90; @@ -163,6 +163,29 @@ fn make_offline_shows_the_slate_until_relinked() { let _ = std::fs::remove_dir_all(&root); } +#[cfg(unix)] +#[test] +fn relink_search_does_not_follow_directory_symlinks() { + use std::os::unix::fs::symlink; + + let root = tmp_dir("relink-no-symlink-walk"); + let inside = root.join("inside"); + let outside = root.join("outside"); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(inside.join("kept.mov"), b"kept").unwrap(); + std::fs::write(outside.join("outside.mov"), b"outside").unwrap(); + symlink(&outside, inside.join("loop")).unwrap(); + symlink(outside.join("outside.mov"), inside.join("linked.mov")).unwrap(); + + let index = index_folder(&inside, 20_000); + assert!(index.contains_key("kept.mov")); + assert!(!index.contains_key("outside.mov")); + // a symlinked file is still media the user can relink to + assert!(index.contains_key("linked.mov")); + let _ = std::fs::remove_dir_all(&root); +} + // Regression test for issue #110: after `file.import` re-imports a file at a path the project // already listed as offline, `s.offline.missing` must drop that item so the slate and the // "Media missing" badge clear without reopening the project. Before the fix (the patch in diff --git a/crates/engine/src/vfx_tests.rs b/crates/engine/src/vfx_tests.rs index f1aeb5987..20ddf0d1e 100644 --- a/crates/engine/src/vfx_tests.rs +++ b/crates/engine/src/vfx_tests.rs @@ -128,3 +128,27 @@ fn ultra_key_setting_writes_aggressive_parameters() { assert_eq!(e.param("choke").unwrap().value.as_f64(), Some(10.0)); assert_eq!(e.param("contrast").unwrap().value.as_f64(), Some(10.0)); } + +/// #2679: a value outside a parameter's range (the `min`/`max` `effects.list` publishes) is +/// refused with the range, not saved: gamma 1.1 meant as a factor turned the frame black. +#[test] +fn set_param_refuses_values_outside_the_range() { + let mut s = demo(); + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0; + s.execute("effects.apply", json!({"clips": [clip], "effect": "levels"})).unwrap(); + s.execute("effects.apply", json!({"clips": [clip], "effect": "lumetri"})).unwrap(); + let before = s.project.to_json(); + let undo = s.history.undo.len(); + for (effect, param, value) in + [("levels", "gamma", 1.1), ("lumetri", "saturation", 500.0), ("lumetri", "vibrance", -300.0), ("opacity", "opacity", 250.0), ("motion", "scale", -50.0)] + { + let e = s.execute("effects.setParam", json!({"clip": clip, "effect": effect, "param": param, "value": value})).unwrap_err().to_string(); + assert!(e.contains("must be between"), "{effect}.{param} = {value}: {e}"); + } + assert_eq!(s.project.to_json(), before, "nothing was saved"); + assert_eq!(s.history.undo.len(), undo); + // the ends of the range and values inside it still go in + s.execute("effects.setParam", json!({"clip": clip, "effect": "levels", "param": "gamma", "value": 110})).unwrap(); + s.execute("effects.setParam", json!({"clip": clip, "effect": "opacity", "param": "opacity", "value": 100})).unwrap(); + s.execute("effects.setParam", json!({"clip": clip, "effect": "opacity", "param": "opacity", "value": 0})).unwrap(); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 46956912f..fbd3413fd 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -406,6 +406,10 @@ pub struct ExportSettings { /// writing `path` (hosts without a filesystem: the web app offers the file as a download). #[serde(skip)] pub sink: Option, + /// An already-created primary output file. Internal callers use this to retain an exclusive + /// reservation made while choosing an output name, rather than reopening that path later. + #[serde(skip)] + pub output_file: Option>, } /// Receives in-memory export output: `(path, bytes)` per finished file. @@ -432,6 +436,10 @@ enum Out { impl Out { fn create(settings: &ExportSettings) -> Result { + if let Some(file) = &settings.output_file { + let file = file.try_clone().map_err(|e| ExportError::Io(e.to_string()))?; + return Ok(Out::File(std::io::BufWriter::new(file))); + } Out::create_path(settings, &settings.path) } @@ -440,6 +448,12 @@ impl Out { if settings.sink.is_some() { return Ok(Out::Mem(std::io::Cursor::new(Vec::new()))); } + if path == settings.path + && let Some(file) = &settings.output_file + { + let file = file.try_clone().map_err(|e| ExportError::Io(e.to_string()))?; + return Ok(Out::File(std::io::BufWriter::new(file))); + } let f = std::fs::File::create(path).map_err(|e| ExportError::Io(format!("{path}: {e}")))?; Ok(Out::File(std::io::BufWriter::new(f))) } @@ -588,6 +602,7 @@ impl Default for ExportSettings { h264_pass: H264Pass::Single, signal: ColorSignal::default(), sink: None, + output_file: None, } } } diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs new file mode 100644 index 000000000..3452f185c --- /dev/null +++ b/crates/export/tests/it/main.rs @@ -0,0 +1,2 @@ +mod pool_trim; +mod renderer_reentrancy; diff --git a/crates/export/tests/pool_trim.rs b/crates/export/tests/it/pool_trim.rs similarity index 100% rename from crates/export/tests/pool_trim.rs rename to crates/export/tests/it/pool_trim.rs diff --git a/crates/export/tests/renderer_reentrancy.rs b/crates/export/tests/it/renderer_reentrancy.rs similarity index 98% rename from crates/export/tests/renderer_reentrancy.rs rename to crates/export/tests/it/renderer_reentrancy.rs index fc6c235bc..2c3a8bd8a 100644 --- a/crates/export/tests/renderer_reentrancy.rs +++ b/crates/export/tests/it/renderer_reentrancy.rs @@ -143,7 +143,7 @@ fn nested_rayon_renderers_finish_every_export_path() { } for case in ["png", "gif", "stepped", "overlap-audio"] { let mut child = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "nested_rayon_renderers_finish_every_export_path", "--nocapture"]) + .args(["--exact", "renderer_reentrancy::nested_rayon_renderers_finish_every_export_path", "--nocapture"]) .env(CHILD, case) .spawn() .unwrap(); diff --git a/crates/flac/src/lib.rs b/crates/flac/src/lib.rs index 0844b7c3c..10d8d28fa 100644 --- a/crates/flac/src/lib.rs +++ b/crates/flac/src/lib.rs @@ -111,7 +111,9 @@ impl Encoder { total_samples: 0, min_frame: 0, max_frame: 0, - md5: md5::Md5::default(), + // RFC 9639 requires an MD5 signature of the unencoded audio in STREAMINFO; this is an + // integrity checksum, not a password hash or a security boundary. + md5: md5::Md5::default(), // NOSONAR }) } diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index f2c1e982e..87c6be25b 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -16,6 +16,10 @@ log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = "3.27" [dev-dependencies] filmcraft-time = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 0f1044ca6..1e4d06a6a 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -46,8 +46,38 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> { Ok(()) } -/// Best-effort directory fsync (persists the rename on POSIX; not possible on Windows, where -/// `MoveFileEx` with write-through semantics is already durable enough). +/// Publish a complete new file without replacing an occupied final path, including a symlink. +/// Bytes are staged and synced in the destination directory before no-clobber publication. +/// A write failure leaves no partial final file; temporary-file cleanup is best effort. +#[cfg(not(target_arch = "wasm32"))] +pub fn atomic_write_new(path: &Path, data: &[u8]) -> io::Result<()> { + atomic_write_new_with(path, |file| file.write_all(data).and_then(|_| file.sync_all())) +} + +#[cfg(target_arch = "wasm32")] +pub fn atomic_write_new(_path: &Path, _data: &[u8]) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "native exclusive file creation is unavailable in the browser")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn atomic_write_new_with(path: &Path, write: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + let dir = parent_dir(path); + #[cfg(unix)] + let mut staged = { + use std::os::unix::fs::PermissionsExt; + // Like an ordinary file creation: request 0666 and let the process umask decide the + // effective mode (0644 with the usual 022, 0664 with 002). + tempfile::Builder::new().permissions(fs::Permissions::from_mode(0o666)).tempfile_in(&dir)? // NOSONAR + }; + #[cfg(not(unix))] + let mut staged = tempfile::NamedTempFile::new_in(&dir)?; + write(staged.as_file_mut())?; + let _published = staged.persist_noclobber(path).map_err(|e| e.error)?; + sync_dir(&dir); + Ok(()) +} + +/// Best-effort directory sync; directory handles are not uniformly readable or syncable. fn sync_dir(dir: &Path) { if let Ok(d) = File::open(dir) { let _ = d.sync_all(); @@ -92,6 +122,66 @@ mod tests { fs::remove_dir_all(&d).unwrap(); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn new_file_is_published_complete_without_clobbering_or_temp_leaks() { + let d = tmpdir("new"); + let p = d.join("audio.wav"); + atomic_write_new(&p, b"complete").unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let reference = d.join("normal-create"); + OpenOptions::new().write(true).create_new(true).open(&reference).unwrap(); + assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, fs::metadata(&reference).unwrap().permissions().mode() & 0o777); + fs::remove_file(reference).unwrap(); + } + assert_eq!(atomic_write_new(&p, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + #[cfg(unix)] + { + let link = d.join("link.wav"); + std::os::unix::fs::symlink(&p, &link).unwrap(); + assert_eq!(atomic_write_new(&link, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + } + let expected = if cfg!(unix) { 2 } else { 1 }; + assert_eq!(fs::read_dir(&d).unwrap().count(), expected, "staging file remained after publication"); + fs::remove_dir_all(&d).unwrap(); + } + + #[cfg(unix)] + #[test] + fn new_file_can_publish_in_a_directory_without_read_permission() { + use std::os::unix::fs::PermissionsExt; + let d = tmpdir("write-only-directory"); + let original_permissions = fs::metadata(&d).unwrap().permissions(); + fs::set_permissions(&d, fs::Permissions::from_mode(0o300)).unwrap(); + let p = d.join("audio.wav"); + let result = atomic_write_new(&p, b"complete"); + fs::set_permissions(&d, original_permissions).unwrap(); + assert!(result.is_ok(), "{result:?}"); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + fs::remove_dir_all(&d).unwrap(); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn failed_staging_leaves_no_partial_destination() { + let d = tmpdir("failed-new"); + let p = d.join("audio.wav"); + let error = atomic_write_new_with(&p, |file| { + file.write_all(b"partial")?; + Err(io::Error::other("simulated full disk")) + }) + .unwrap_err(); + assert_eq!(error.to_string(), "simulated full disk"); + assert!(!p.exists()); + assert_eq!(fs::read_dir(&d).unwrap().count(), 0); + fs::remove_dir_all(&d).unwrap(); + } + #[test] fn missing_directory_is_an_error() { let d = tmpdir("missing"); diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 4608c1061..54543749b 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -1,9 +1,10 @@ //! Auto-save file naming and rotation (Premiere behaviour). //! //! Auto-saves go to an `Auto-Save` folder next to the project, named -//! `-YYYY-MM-DD_HH-MM-SS.fcproj` (local wall-clock time). After each write the oldest -//! files of that project beyond the "Maximum Project Versions" limit are deleted. Only files whose -//! name is exactly `-.fcproj` are ever touched. +//! `-YYYY-MM-DD_HH-MM-SS-.fcproj` (local wall-clock time, then the real +//! time that orders the files). After each write the oldest files of that project beyond the "Maximum +//! Project Versions" limit are deleted. Only files whose name is exactly `-.fcproj` +//! or `--.fcproj` are ever touched. use std::fs; use std::io; @@ -24,23 +25,37 @@ pub fn file_stem(name: &str) -> String { if s.is_empty() { "Untitled".into() } else { s } } +/// Unix seconds → (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 +/// `i64` exact instead of collapsing out-of-range years to the epoch. +fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + 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, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + /// Civil date-time (proleptic Gregorian) of a unix timestamp shifted by `offset_secs`. +/// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept them); +/// only an overflowing `unix + offset_secs` shift (which the old code wrapped to garbage) reads as +/// the epoch. pub fn civil(unix: i64, offset_secs: i32) -> (i64, u32, u32, u32, u32, u32) { - let t = unix + offset_secs as i64; - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - // Howard Hinnant's days_from_civil inverse. - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d, (secs / 3600) as u32, (secs % 3600 / 60) as u32, (secs % 60) as u32) + use chrono::{Datelike, Timelike}; + let Some(s) = unix.checked_add(offset_secs as i64) else { + return (1970, 1, 1, 0, 0, 0); + }; + let (y, t) = utc_parts(s); + (y, t.month(), t.day(), t.hour(), t.minute(), t.second()) } /// `YYYY-MM-DD_HH-MM-SS` (the auto-save file-name stamp). @@ -55,9 +70,10 @@ pub fn display_time(unix: i64, offset_secs: i32) -> String { format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}") } -/// `-YYYY-MM-DD_HH-MM-SS.fcproj`. +/// `-YYYY-MM-DD_HH-MM-SS-.fcproj`. The local stamp is for people; the unix time +/// orders the files (the local clock can go back, or repeat an hour, when daylight saving time ends). pub fn auto_save_name(name: &str, unix: i64, offset_secs: i32) -> String { - format!("{}-{}.fcproj", file_stem(name), timestamp(unix, offset_secs)) + format!("{}-{}-{unix}.fcproj", file_stem(name), timestamp(unix, offset_secs)) } fn is_stamp(s: &str) -> bool { @@ -70,20 +86,33 @@ fn is_stamp(s: &str) -> bool { }) } -/// Auto-saves of project `name` in `dir`, oldest first. +/// Parse `YYYY-MM-DD_HH-MM-SS` or `YYYY-MM-DD_HH-MM-SS-` into the stamp and the unix time +/// (`None` for files written before the unix time was part of the name). +fn parse_stamp(s: &str) -> Option<(&str, Option)> { + if is_stamp(s) { + return Some((s, None)); + } + let stamp = s.get(..19).filter(|st| is_stamp(st))?; + let digits = s.get(19..)?.strip_prefix('-')?; + let unix: i64 = digits.parse().ok()?; + (unix.to_string() == digits).then_some((stamp, Some(unix))) +} + +/// Auto-saves of project `name` in `dir`, oldest first (by real time, not by the local clock; files +/// without a unix time in their name are older than those with one). pub fn list_auto_saves(dir: &Path, name: &str) -> Vec { let prefix = format!("{}-", file_stem(name)); let Ok(rd) = fs::read_dir(dir) else { return Vec::new() }; - let mut v: Vec<(String, PathBuf)> = rd + let mut v: Vec<(Option, String, PathBuf)> = rd .filter_map(|e| e.ok()) .filter_map(|e| { let f = e.file_name().to_string_lossy().into_owned(); - let stamp = f.strip_prefix(&prefix)?.strip_suffix(".fcproj")?; - is_stamp(stamp).then(|| (stamp.to_string(), e.path())) + let (stamp, unix) = parse_stamp(f.strip_prefix(&prefix)?.strip_suffix(".fcproj")?)?; + Some((unix, stamp.to_string(), e.path())) }) .collect(); v.sort(); - v.into_iter().map(|(_, p)| p).collect() + v.into_iter().map(|(_, _, p)| p).collect() } /// Delete the oldest auto-saves of `name` so at most `max` remain. Returns the deleted paths. @@ -122,14 +151,60 @@ mod tests { assert_eq!(timestamp(-1, 0), "1969-12-31_23-59-59"); } + #[test] + fn out_of_range_years_are_preserved_not_collapsed_to_the_epoch() { + // The old hand-rolled conversion kept years far outside chrono's own range; the refactor + // must too (reviewer FredApps). `10_000_000_000_000` s → 318857, not 1970. + assert_eq!(timestamp(10_000_000_000_000, 0), "318857-05-20_17-46-40"); + assert_eq!(display_time(10_000_000_000_000, 0), "318857-05-20 17:46:40"); + // chrono's own upper edge (year 10000) and a negative value far before it. + assert_eq!(timestamp(253_402_300_800, 0), "10000-01-01_00-00-00"); + assert_eq!(display_time(-10_000_000_000_000, 0), "-314918-08-13 06:13:20"); + // The input extremes the old code accepted (offset 0, so no shift overflow). + assert_eq!(display_time(i64::MAX, 0), "292277026596-12-04 15:30:07"); + assert_eq!(display_time(i64::MIN, 0), "-292277022657-01-27 08:29:52"); + // Only an overflowing `unix + offset_secs` shift (old code wrapped it to garbage) reads as + // the epoch; the separate overflow guard is kept. + assert_eq!(civil(i64::MAX, 3600), (1970, 1, 1, 0, 0, 0)); + assert_eq!(civil(i64::MIN, -3600), (1970, 1, 1, 0, 0, 0)); + } + #[test] fn names_and_stems() { - assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00.fcproj"); + assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00-0.fcproj"); assert_eq!(file_stem("a/b:c"), "a_b_c"); assert_eq!(file_stem(" "), "Untitled"); assert_eq!(auto_save_dir(Path::new("/x/y/p.fcproj")), Path::new("/x/y/Auto-Save")); } + #[test] + fn rotation_survives_the_clock_going_back_at_the_end_of_dst() { + let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + let t0 = 1_792_900_000; + // 02:30 at +02:00, then 40 minutes later the clock reads 02:10 at +01:00. + let old = write_auto_save(&d, "Cut", t0, 7200, b"old", 1).unwrap(); + let new = write_auto_save(&d, "Cut", t0 + 2400, 3600, b"new", 1).unwrap(); + assert!(new.exists(), "the newest auto-save was deleted by rotation"); + assert!(!old.exists(), "the oldest auto-save should be rotated out"); + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn repeated_wall_clock_hour_does_not_overwrite_earlier_saves() { + let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst2-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + let t0 = 1_792_900_000; + // The same wall-clock second, one hour apart (+02:00 then +01:00). + let a = write_auto_save(&d, "Cut", t0, 7200, b"first", 5).unwrap(); + let b = write_auto_save(&d, "Cut", t0 + 3600, 3600, b"second", 5).unwrap(); + assert_ne!(a, b); + assert_eq!(fs::read(&a).unwrap(), b"first"); + assert_eq!(fs::read(&b).unwrap(), b"second"); + assert_eq!(list_auto_saves(&d, "Cut"), vec![a, b]); + fs::remove_dir_all(&d).unwrap(); + } + #[test] fn rotation_keeps_newest_and_ignores_other_files() { let d = std::env::temp_dir().join(format!("filmcraft-rotate-{}", std::process::id())); @@ -142,8 +217,8 @@ mod tests { fs::write(d.join("notes.txt"), b"keep").unwrap(); let left = list_auto_saves(&d, "Cut"); assert_eq!(left.len(), 5); - assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_120, 0)))); - assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_360, 0)))); + assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}-1790000120.fcproj", timestamp(1_790_000_120, 0)))); + assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}-1790000360.fcproj", timestamp(1_790_000_360, 0)))); assert_eq!(list_auto_saves(&d, "Cut-Two").len(), 1); assert!(d.join("notes.txt").exists()); rotate(&d, "Cut", 2).unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 70af3c747..9ecd10aab 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -49,7 +49,7 @@ use filmcraft_project::{Project, ProjectView}; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub use atomic::atomic_write; +pub use atomic::{atomic_write, atomic_write_new}; /// The `format` tag written in every envelope. pub const FORMAT_ID: &str = "filmcraft.project"; @@ -244,6 +244,18 @@ fn validate_loaded(mut loaded: Loaded) -> Result { sequence.check_bounds().map_err(|e| FormatError::Corrupt(format!("sequence `{}`: {e}", item.name)))?; } } + // Every new id is `next_id`, read from the file as is. One that lags behind the ids in it (an + // older build, a hand edit) would hand out an id in use, and the new item replace the old (#2809). + let project = &mut loaded.project; + let max = project.max_id(); + let past = max.checked_add(1).ok_or_else(|| FormatError::Corrupt(format!("project ID space exhausted (id {max} in use)")))?; + if project.next_id < past { + log::warn!("next id {} is not past the largest id in the project, raised to {past}", project.next_id); + project.next_id = past; + } + if project.next_id > Project::MAX_NEXT_ID { + return Err(FormatError::Corrupt(format!("project ID space exhausted (next id {})", project.next_id))); + } Ok(loaded) } diff --git a/crates/format/tests/fixtures.rs b/crates/format/tests/fixtures.rs index e99a7853f..6f1c9922a 100644 --- a/crates/format/tests/fixtures.rs +++ b/crates/format/tests/fixtures.rs @@ -35,7 +35,8 @@ fn v1_edit_loads_with_defaults_for_later_fields() { assert_eq!(l.schema_version, 1); let p = &l.project; assert_eq!(p.name, "Legacy Edit"); - assert_eq!(p.next_id, 48); + // the file says 48, but its sequence marker is 800000: new ids start past it (#2809) + assert_eq!(p.next_id, 800_001); let media = p.item(ItemId(5)).unwrap(); assert_eq!(media.name, "Ocean_Sunset.mp4"); assert!(media.metadata.is_empty()); @@ -219,6 +220,8 @@ fn v8_loads_without_transcripts_and_v9_roundtrips_them() { t.normalize(); p.transcripts.insert(ItemId(7), std::sync::Arc::new(t)); let again = decode(&encode(&p, true)).unwrap(); + // the transcript's key is an id in use, so new ids start past it (#2809) + p.next_id = 8; assert!(!again.migrated()); assert_eq!(again.project, p); assert_eq!(again.project.transcripts[&ItemId(7)].speakers[0].name, "Speaker 1"); @@ -259,3 +262,200 @@ fn v10_minimal_loads_with_m3_11_defaults() { assert_eq!(again.schema_version, SCHEMA_VERSION); assert_eq!(&again.project, p); } + +/// #2809: `next_id` is read from the file, and nothing raised it past the ids already in it. A file +/// whose `next_id` lagged behind (an older build, a hand edit, a merge) handed out an id in use, and +/// the new item silently replaced the old one. Each kind of id is raised past on its own, so a kind +/// the loader forgets fails here. +#[test] +fn next_id_is_raised_past_every_id_in_the_file() { + use filmcraft_project::{ + BinId, Camera, Caption, CaptionFormat, CaptionTrack, Label, Marker, MarkerId, MarkerKind, MergedClip, MulticamSource, Narration, ProjectLut, SearchBin, + Track, TrackId, TrackKind, TrackSend, Transition, TransitionId, + }; + const BIG: u64 = 5_000_000; + let base = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let seq = ItemId(21); + let marker = |id| Marker { + id: MarkerId(id), + start: Tick::ZERO, + duration: Tick::ZERO, + name: String::new(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Green, + }; + let kinds: Vec<(&str, Box)> = vec![ + ( + "item", + Box::new(|p| { + p.next_id = BIG; + p.add_item("Layer", Label::Iris, p.item(ItemId(5)).unwrap().kind.clone(), None); + }), + ), + ( + "bin", + Box::new(|p| { + p.next_id = BIG; + p.add_bin("Bin", None); + }), + ), + ("search bin", Box::new(|p| p.search_bins.push(SearchBin { id: BinId(BIG), name: "Find".into(), query: Default::default() }))), + ("media marker", Box::new(move |p| p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap().markers.push(marker(BIG)))), + ("sequence marker", Box::new(move |p| p.sequence_mut(seq).unwrap().markers[0].id = MarkerId(BIG))), + ("track", Box::new(move |p| p.sequence_mut(seq).unwrap().audio_tracks[0].id = TrackId(BIG))), + ("submix track", Box::new(move |p| p.sequence_mut(seq).unwrap().submix_tracks.push(Track::new(TrackId(BIG), TrackKind::Audio, "Submix".into())))), + ("clip", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].id = ClipId(BIG))), + ("link", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].link = Some(BIG))), + ("group", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].group = Some(BIG))), + ("clip marker", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].markers.push(marker(BIG)))), + ( + "transition", + Box::new(move |p| { + let s = p.sequence_mut(seq).unwrap(); + let clip = s.video_tracks[0].items[0].clone(); + let effect = clip.effects[0].clone(); + s.video_tracks[0].transitions.push(Transition { + id: TransitionId(BIG), + effect, + start: clip.start, + duration: Tick::ZERO, + from: None, + to: Some(clip.id), + align: Default::default(), + reverse: false, + }); + }), + ), + ( + "caption track", + Box::new(move |p| p.sequence_mut(seq).unwrap().caption_tracks.push(CaptionTrack::new(TrackId(BIG), "Subtitle".into(), CaptionFormat::Subtitle))), + ), + ( + "caption", + Box::new(move |p| { + let mut track = CaptionTrack::new(TrackId(7), "Subtitle".into(), CaptionFormat::Subtitle); + track.captions.push(Caption { + id: ClipId(BIG), + start: Tick::ZERO, + duration: Tick(1), + text: "Hi".into(), + speaker: None, + cue_id: None, + settings: String::new(), + }); + p.sequence_mut(seq).unwrap().caption_tracks.push(track); + }), + ), + ( + "LUT", + Box::new(|p| p.luts.push(ProjectLut { id: format!("lut{BIG}"), name: "Look".into(), source_path: None, format: "cube".into(), text: "".into() })), + ), + // left behind by a deleted item: a new item must not take its id and inherit them + ("transcript", Box::new(|p| _ = p.transcripts.insert(ItemId(BIG), Default::default()))), + ("source graphic", Box::new(|p| _ = p.source_graphics.insert(ItemId(BIG), Default::default()))), + ( + "narration", + Box::new(|p| { + let narration = Narration { + text: "Hello".into(), + language: "en-US".into(), + voice: "basic-female".into(), + pitch: Default::default(), + pace: 1.0, + speech_duration: Tick(1), + }; + p.narrations.insert(ItemId(BIG), narration); + }), + ), + // only referenced, the target gone: a new item taking that id would be picked up by the reference + ("clip's item", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].item = ItemId(BIG))), + ( + "subclip's parent", + Box::new(|p| { + let range = filmcraft_time::TimeRange::new(Tick::ZERO, Tick(1)); + p.add_item("Subclip", Label::Iris, ItemKind::Subclip { parent: ItemId(BIG), range, restrict_trims: false }, None); + }), + ), + ( + "transition's clip", + Box::new(move |p| { + let s = p.sequence_mut(seq).unwrap(); + let clip = s.video_tracks[0].items[0].clone(); + s.video_tracks[0].transitions.push(Transition { + id: TransitionId(9), + effect: clip.effects[0].clone(), + start: clip.start, + duration: Tick::ZERO, + from: None, + to: Some(ClipId(BIG)), + align: Default::default(), + reverse: false, + }); + }), + ), + ("send target", Box::new(move |p| p.sequence_mut(seq).unwrap().audio_tracks[0].mixer.sends.push(TrackSend::new(TrackId(BIG))))), + ("track output", Box::new(move |p| p.sequence_mut(seq).unwrap().master_mixer.output = Some(TrackId(BIG)))), + ( + "camera's source", + Box::new(move |p| { + let camera = Camera { name: "Cam 1".into(), video_track: None, audio_tracks: Vec::new(), enabled: true, source: Some(ItemId(BIG)) }; + p.sequence_mut(seq).unwrap().multicam = Some(MulticamSource { cameras: vec![camera], ..Default::default() }); + }), + ), + ( + "camera's track", + Box::new(move |p| { + let camera = Camera { name: "Cam 1".into(), video_track: Some(TrackId(BIG)), audio_tracks: Vec::new(), enabled: true, source: None }; + p.sequence_mut(seq).unwrap().multicam = Some(MulticamSource { cameras: vec![camera], ..Default::default() }); + }), + ), + ("merged clip", Box::new(move |p| p.sequence_mut(seq).unwrap().merged = Some(MergedClip { audio: vec![ItemId(BIG)], ..Default::default() }))), + ]; + for (kind, add) in &kinds { + let mut p = base.clone(); + add(&mut p); + p.next_id = 1; + let loaded = decode(&encode(&p, false)).unwrap_or_else(|e| panic!("{kind}: {e}")); + assert_eq!(loaded.project.next_id, BIG + 1, "{kind}"); + } +} + +/// #2809: the case the issue describes. A file whose `next_id` names an item already in it: the next +/// new item used to take that id and replace the item. It gets a fresh id, and both are still there +/// after a save and a reload. +#[test] +fn a_new_item_never_replaces_one_from_a_file_with_a_stale_next_id() { + let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project; + p.next_id = 5; + let mut p = decode(&encode(&p, false)).unwrap().project; + let before = p.items.len(); + let kind = p.item(ItemId(5)).unwrap().kind.clone(); + let new = p.add_item("Copy", filmcraft_project::Label::Iris, kind, None); + assert_ne!(new, ItemId(5)); + assert_eq!(p.items.len(), before + 1, "a new item replaced an old one"); + assert_eq!(p.item(ItemId(5)).unwrap().name, "Ocean_Sunset.mp4"); + let again = decode(&encode(&p, false)).unwrap().project; + assert_eq!(again, p, "the round trip keeps both items and the raised next_id"); + assert!(again.next_id > new.0); +} + +/// #2809: ids past what can ever be allocated. The largest possible id has no successor, and a +/// `next_id` that high overflows `alloc_id`; such a file is refused instead of panicking (debug) or +/// wrapping to 0 (release) on the first new item. +#[test] +fn an_exhausted_id_space_is_refused_on_load() { + use filmcraft_project::MarkerId; + let base = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let mut p = base.clone(); + p.sequence_mut(ItemId(21)).unwrap().markers[0].id = MarkerId(u64::MAX); + let e = decode(&encode(&p, false)).unwrap_err(); + assert!(matches!(&e, FormatError::Corrupt(m) if m.contains("ID space exhausted")), "{e}"); + let mut p = base.clone(); + p.next_id = u64::MAX; + let e = decode(&encode(&p, false)).unwrap_err(); + assert!(matches!(&e, FormatError::Corrupt(m) if m.contains("ID space exhausted")), "{e}"); + let mut p = base; + p.next_id = Project::MAX_NEXT_ID; + assert_eq!(decode(&encode(&p, false)).unwrap().project.next_id, Project::MAX_NEXT_ID, "the last usable next_id still opens"); +} diff --git a/crates/h264/README.md b/crates/h264/README.md index 21e7a10f1..634a2977f 100644 --- a/crates/h264/README.md +++ b/crates/h264/README.md @@ -108,7 +108,7 @@ with ffmpeg + libx264; tests print a message and skip when `/opt/homebrew/bin/ff explicit bi-prediction. The expected output is known exactly and is also checked against ffmpeg. - Robustness: avcC input with pts round trip; randomly corrupted, truncated and garbage input never panics or deadlocks (single- and multi-threaded). -- Conformance (`tests/conformance.rs`): every fixture is decoded single-threaded, with 3 threads +- Conformance (`tests/it/conformance.rs`): every fixture is decoded single-threaded, with 3 threads and with every core, and compared byte-for-byte with `ffmpeg -f rawvideo -pix_fmt yuv420p`; on mismatch the first differing frame, plane, sample position and macroblock are reported. Output pts/POC order is checked too, and that no picture is flagged draft without draft mode. Draft @@ -164,12 +164,12 @@ encoder is unavailable. | vt_high | testsrc2+noise 640x360 | VideoToolbox, High | | vt_high_1080p | testsrc2 1920x1080 | VideoToolbox, High 8 Mbit/s | -`cargo test --release -p filmcraft-h264 --test conformance coverage_report -- --ignored --nocapture` +`cargo test --release -p filmcraft-h264 --test it conformance::coverage_report -- --ignored --nocapture` prints which coding tools each fixture exercises. ## Performance -`cargo test --release -p filmcraft-h264 --test perf -- --ignored --nocapture` (1080p High profile, +`cargo test --release -p filmcraft-h264 --test it perf:: -- --ignored --nocapture` (1080p High profile, CABAC, B-pyramid, 120 frames, 8.8 Mbit/s; bit-exactness is verified first). Measured on an Apple M4 Pro (14 cores) while the machine was heavily shared (load average 40-180), so these are lower bounds: diff --git a/crates/h264/tests/fuzz_hostile.rs b/crates/h264/tests/fuzz_hostile.rs new file mode 100644 index 000000000..92f7bdc6c --- /dev/null +++ b/crates/h264/tests/fuzz_hostile.rs @@ -0,0 +1,194 @@ +//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and +//! random mutation of small libx264 streams must never panic or run away. Skips without ffmpeg. + +#[path = "it/common/mod.rs"] +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; + +use filmcraft_h264::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, extra x264 args, pix_fmt) +const SEEDS: &[(&str, &str, &str)] = &[ + ("h264_fz_cabac_b", "-profile:v high -bf 2 -g 8 -x264-params cabac=1:weightp=2:8x8dct=1", "yuv420p"), + ("h264_fz_cavlc", "-profile:v baseline -g 6 -x264-params cabac=0", "yuv420p"), + ("h264_fz_interlaced", "-profile:v high -flags +ildct+ilme -bf 1 -g 6 -x264-params tff=1:interlaced=1", "yuv420p"), + ("h264_fz_10bit_422", "-profile:v high422 -bf 1 -g 6", "yuv422p10le"), + ("h264_fz_multislice", "-profile:v main -g 6 -x264-params slices=3:cabac=1", "yuv420p"), +]; + +fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option> { + let out = filmcraft_testkit::fixtures_dir("h264").join(format!("{name}.h264")); + let p = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let a: Vec<&str> = args.split_whitespace().collect(); + matches!( + Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", "libx264"]) + .args(a) + .args(["-f", "h264"]) + .arg(tmp) + .stdin(Stdio::null()) + .status(), + Ok(s) if s.success() + ) + })?; + std::fs::read(p).ok() +} + +static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + for threads in [1, 3] { + let mut dec = Decoder::with_threads(threads); + for au in common::split_access_units(d) { + let _ = dec.decode(au, 0); + } + let _ = dec.decode(d, 1); + let _ = dec.flush(); + let _ = dec.take_error(); + } + })); + assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=400` runs all of it (see [`Depth`]). +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("h264 fuzz") else { return }; + let depth = Depth::from_env(); + for &(name, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libx264") { + filmcraft_testkit::skip("h264 fuzz", &format!("this ffmpeg has no libx264 encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libx264 but could not encode the {name} seed"); + }; + for n in depth.truncations(seed.len()) { + run_all(&seed[..n], &format!("{name} truncate {n}")); + } + // SPS/PPS/first slice header region: every byte to a few values, every bit + for p in 0..seed.len().min(120) { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } + let mut d = seed.clone(); + d[p] = v; + run_all(&d, &format!("{name} byte {p}={v:#x}")); + } + for b in 0..8 { + if !depth.bit(p, b) { + continue; + } + let mut d = seed.clone(); + d[p] ^= 1 << b; + run_all(&d, &format!("{name} flip {p}.{b}")); + } + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(6) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + if depth.round(i) { + run_all(&d, &format!("{name} random {i}")); + } + } + } +} + +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 300 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 400 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 300, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=400 runs everything)"); + Depth { full: false, truncations: 300, rounds: 400, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} diff --git a/crates/h264/tests/common/mod.rs b/crates/h264/tests/it/common/mod.rs similarity index 99% rename from crates/h264/tests/common/mod.rs rename to crates/h264/tests/it/common/mod.rs index e100c3289..2de4d62d2 100644 --- a/crates/h264/tests/common/mod.rs +++ b/crates/h264/tests/it/common/mod.rs @@ -64,7 +64,7 @@ pub const FIXTURES: &[Fixture] = &[ filter: "", args: &["-c:v", "h264_videotoolbox", "-profile:v", "high", "-b:v", "8M"], }, - // performance reference stream (see tests/perf.rs) + // performance reference stream (see tests/it/perf.rs) Fixture { name: "bench_1080p", source: "testsrc2", diff --git a/crates/h264/tests/conformance.rs b/crates/h264/tests/it/conformance.rs similarity index 99% rename from crates/h264/tests/conformance.rs rename to crates/h264/tests/it/conformance.rs index c06694b79..a55d9fe40 100644 --- a/crates/h264/tests/conformance.rs +++ b/crates/h264/tests/it/conformance.rs @@ -1,6 +1,6 @@ //! Bit-exactness against ffmpeg's decoder on libx264-generated fixtures (skipped when ffmpeg is absent). -mod common; +use crate::common; macro_rules! fixture_tests { ($($name:ident),* $(,)?) => { diff --git a/crates/h264/tests/headers.rs b/crates/h264/tests/it/headers.rs similarity index 99% rename from crates/h264/tests/headers.rs rename to crates/h264/tests/it/headers.rs index f0f7a59e3..f27a7ed01 100644 --- a/crates/h264/tests/headers.rs +++ b/crates/h264/tests/it/headers.rs @@ -1,6 +1,6 @@ //! Parameter set / slice header / POC parsing over the fixture streams. -mod common; +use crate::common; use filmcraft_bitstream::{annexb_nals, unescape_rbsp}; use filmcraft_h264::params::{Pps, Sps}; diff --git a/crates/h264/tests/it/main.rs b/crates/h264/tests/it/main.rs new file mode 100644 index 000000000..ceefd95be --- /dev/null +++ b/crates/h264/tests/it/main.rs @@ -0,0 +1,6 @@ +mod common; +mod conformance; +mod headers; +mod perf; +mod pps_validation; +mod robustness; diff --git a/crates/h264/tests/perf.rs b/crates/h264/tests/it/perf.rs similarity index 91% rename from crates/h264/tests/perf.rs rename to crates/h264/tests/it/perf.rs index c8ed4beb8..da744c24b 100644 --- a/crates/h264/tests/perf.rs +++ b/crates/h264/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode speed on a 1080p High-profile stream (ignored by default; run with -//! `cargo test --release -p filmcraft-h264 --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-h264 --test it perf:: -- --ignored --nocapture`). -mod common; +use crate::common; use filmcraft_h264::Decoder; use std::time::Instant; diff --git a/crates/h264/tests/pps_validation.rs b/crates/h264/tests/it/pps_validation.rs similarity index 100% rename from crates/h264/tests/pps_validation.rs rename to crates/h264/tests/it/pps_validation.rs diff --git a/crates/h264/tests/robustness.rs b/crates/h264/tests/it/robustness.rs similarity index 99% rename from crates/h264/tests/robustness.rs rename to crates/h264/tests/it/robustness.rs index 902ab191d..898d50bef 100644 --- a/crates/h264/tests/robustness.rs +++ b/crates/h264/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! avcC / length-prefixed input and resilience against damaged streams. -mod common; +use crate::common; use filmcraft_bitstream::annexb_nals; use filmcraft_h264::Decoder; diff --git a/crates/h264enc/examples/h264enc_synth.rs b/crates/h264enc/examples/h264enc_synth.rs index 15ab55fd9..3d1b25f4c 100644 --- a/crates/h264enc/examples/h264enc_synth.rs +++ b/crates/h264enc/examples/h264enc_synth.rs @@ -2,7 +2,7 @@ //! //! `cargo run --release -p filmcraft-h264enc --example h264enc_synth -- W H FRAMES [profile] [preset] [bframes] [rate] [slices] [src]` //! profile: baseline|main|high, preset: speed|balanced|quality, rate: qpN | crfN | cbrN | vbrN -#[path = "../tests/common/mod.rs"] +#[path = "../tests/it/common/mod.rs"] mod common; use filmcraft_h264enc::*; diff --git a/crates/h264enc/src/ratecontrol.rs b/crates/h264enc/src/ratecontrol.rs index 9f77a6813..0fb27216c 100644 --- a/crates/h264enc/src/ratecontrol.rs +++ b/crates/h264enc/src/ratecontrol.rs @@ -81,7 +81,7 @@ pub enum RcKind { Qp(u8), Crf(f32), Abr { kbps: u32 }, - TwoPass { kbps: u32, plan: Vec, planned_bits: Vec }, + TwoPass { kbps: u32, plan: Vec, planned_bits: Vec, planned_total: f64, remaining_bits: Vec }, } pub struct RateControl { @@ -153,8 +153,15 @@ impl RateControl { } } let plan = qs((0.5 * (lo + hi)).exp()); - let planned_bits = stats.frames.iter().zip(&plan).map(|(f, &q)| f.bits as f64 * f.qscale / q).collect(); - RcKind::TwoPass { kbps, plan, planned_bits } + let planned_bits: Vec = stats.frames.iter().zip(&plan).map(|(f, &q)| f.bits as f64 * f.qscale / q).collect(); + let planned_total = planned_bits.iter().sum(); + // `remaining_bits[i]` is the inclusive suffix for frame `i`; retain an + // empty suffix so callers can index it without a special case. + let mut remaining_bits = vec![0.0; planned_bits.len() + 1]; + for i in (0..planned_bits.len()).rev() { + remaining_bits[i] = remaining_bits[i + 1] + planned_bits[i]; + } + RcKind::TwoPass { kbps, plan, planned_bits, planned_total, remaining_bits } } /// Choose the frame QP (fractional) for frame `index` (display order) of `t` with lookahead complexity `cplx`. @@ -198,17 +205,17 @@ impl RateControl { let q = rf * cplx.powf(1.0 - QCOMP) * type_factor(t); qscale2qp(q) } - RcKind::TwoPass { kbps, plan, planned_bits } => { + RcKind::TwoPass { kbps, plan, planned_bits, planned_total, remaining_bits } => { // Closed-loop second pass: `m` measures how far real frame sizes deviate from the first-pass model // (bits ∝ 1/qscale) at the qscales actually used; the remaining plan is rescaled so its predicted // size fits the remaining budget. let i = index_in_coding_order.min(plan.len().saturating_sub(1)); let bpf = *kbps as f64 * 1000.0 / self.fps; - let target_total: f64 = planned_bits.iter().sum(); + let target_total = *planned_total; let smooth = 0.5 * bpf * self.fps.max(1.0); let m = ((self.total_bits + smooth) / (self.model_done + smooth)).clamp(0.3, 3.0); let inflight: f64 = self.inflight.iter().sum(); - let remaining_plan: f64 = planned_bits[i..].iter().sum(); + let remaining_plan = remaining_bits[i]; let remaining_budget = (target_total - self.total_bits - m * inflight).max(0.05 * remaining_plan.max(1.0)); let f = (m * remaining_plan / remaining_budget).clamp(0.25, 4.0); let q = plan[i] * f; @@ -271,3 +278,31 @@ impl RateControl { self.pass1.push(FrameStat { slice_type: t, qscale: qp2qscale(qp), bits, cplx }); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_pass_plan_caches_total_and_inclusive_suffixes() { + let stats = PassStats { + fps: 24.0, + frames: vec![ + FrameStat { slice_type: SliceType::I, qscale: 1.0, bits: 2_000, cplx: 20.0 }, + FrameStat { slice_type: SliceType::P, qscale: 1.5, bits: 1_000, cplx: 10.0 }, + FrameStat { slice_type: SliceType::B, qscale: 2.0, bits: 500, cplx: 5.0 }, + ], + }; + + let RcKind::TwoPass { planned_bits, planned_total, remaining_bits, .. } = RateControl::plan_two_pass(&stats, 1_000) else { + panic!("two-pass planning must return a two-pass rate-control plan"); + }; + + assert_eq!(remaining_bits.len(), planned_bits.len() + 1); + assert!((planned_total - planned_bits.iter().sum::()).abs() < f64::EPSILON); + for i in 0..planned_bits.len() { + assert!((remaining_bits[i] - planned_bits[i..].iter().sum::()).abs() < f64::EPSILON); + } + assert_eq!(remaining_bits[planned_bits.len()], 0.0); + } +} diff --git a/crates/h264enc/tests/common/mod.rs b/crates/h264enc/tests/it/common/mod.rs similarity index 100% rename from crates/h264enc/tests/common/mod.rs rename to crates/h264enc/tests/it/common/mod.rs diff --git a/crates/h264enc/tests/crf_level_and_note.rs b/crates/h264enc/tests/it/crf_level_and_note.rs similarity index 98% rename from crates/h264enc/tests/crf_level_and_note.rs rename to crates/h264enc/tests/it/crf_level_and_note.rs index b93ebf66b..58306c073 100644 --- a/crates/h264enc/tests/crf_level_and_note.rs +++ b/crates/h264enc/tests/it/crf_level_and_note.rs @@ -1,7 +1,7 @@ //! Constant quality has no bitrate cap, so the level must allow what low factors spend; and every //! stream says which settings made it. -mod common; +use crate::common; use common::synth; use filmcraft_h264enc::*; diff --git a/crates/h264enc/tests/ffmpeg_oracle.rs b/crates/h264enc/tests/it/ffmpeg_oracle.rs similarity index 99% rename from crates/h264enc/tests/ffmpeg_oracle.rs rename to crates/h264enc/tests/it/ffmpeg_oracle.rs index e19cfc49b..409bf4102 100644 --- a/crates/h264enc/tests/ffmpeg_oracle.rs +++ b/crates/h264enc/tests/it/ffmpeg_oracle.rs @@ -3,7 +3,7 @@ //! Every stream must decode with zero errors, with the exact frame count, and the decoder's output must be //! bit-identical to the encoder's own reconstruction (catches prediction/transform/deblocking mismatches). -mod common; +use crate::common; use common::{Yuv, annexb_nals, ffmpeg, ffmpeg_decode, ffmpeg_source, ffprobe, out_dir, psnr, synth}; use filmcraft_h264enc::*; diff --git a/crates/h264enc/tests/it/main.rs b/crates/h264enc/tests/it/main.rs new file mode 100644 index 000000000..f673a96da --- /dev/null +++ b/crates/h264enc/tests/it/main.rs @@ -0,0 +1,3 @@ +mod common; +mod crf_level_and_note; +mod ffmpeg_oracle; diff --git a/crates/hevc/README.md b/crates/hevc/README.md index 43c266eda..59c762aed 100644 --- a/crates/hevc/README.md +++ b/crates/hevc/README.md @@ -96,7 +96,7 @@ with ffmpeg; tests print a message and skip when ffmpeg is absent). ffmpeg. - Robustness: hvcC input with pts round trip; randomly corrupted, truncated and garbage input never panics or deadlocks (single- and multi-threaded). -- Conformance (`tests/conformance.rs`): every fixture is decoded with 1 thread, 3 threads and every +- Conformance (`tests/it/conformance.rs`): every fixture is decoded with 1 thread, 3 threads and every core and compared sample-exactly with `ffmpeg -f rawvideo -pix_fmt yuv420p / yuv420p10le`; on mismatch the first differing frame, plane, sample, CTB and 8x8 block are reported. Output pts/POC order is checked too, and that no picture is flagged draft without draft mode. `compare_detects_mismatch` @@ -135,12 +135,12 @@ libx265 unless noted (x265 enables WPP by default, so most streams carry entry p | vt_main / vt_main10 | 640x360 | Apple VideoToolbox (hevc_videotoolbox), macOS only | | synthetic (7 streams) | 96x64 | tiles, PCM, slices, dependent segments, LTR, list modification | -`cargo test -p filmcraft-hevc --test conformance coverage_report -- --ignored --nocapture` prints the +`cargo test -p filmcraft-hevc --test it conformance::coverage_report -- --ignored --nocapture` prints the decoder statistics per fixture. ## Performance -`cargo test --release -p filmcraft-hevc --test perf -- --ignored --nocapture` (bit-exactness is +`cargo test --release -p filmcraft-hevc --test it perf:: -- --ignored --nocapture` (bit-exactness is verified first). Apple M4 Pro (14 cores), measured while the machine was heavily shared (load average 50-100), so these are lower bounds: diff --git a/crates/hevc/src/decoder.rs b/crates/hevc/src/decoder.rs index 97a0fb7f9..d6fcbbea9 100644 --- a/crates/hevc/src/decoder.rs +++ b/crates/hevc/src/decoder.rs @@ -340,6 +340,9 @@ impl Decoder { )?; sps.check_supported()?; pps.check_supported()?; + // `start_picture` creates the frame and DPB state. Do not allocate those for a NAL that + // contains only a syntactically valid slice header and no CABAC payload. + ensure!(sh.data_offset < rbsp.len(), "empty slice segment data"); if first { self.start_picture(&sh, &sps, &pps, pts)?; } else { diff --git a/crates/hevc/src/params.rs b/crates/hevc/src/params.rs index a7f7daf0b..9b24d433b 100644 --- a/crates/hevc/src/params.rs +++ b/crates/hevc/src/params.rs @@ -6,6 +6,16 @@ use crate::spec_tables::{DEFAULT_SCALING_INTER, DEFAULT_SCALING_INTRA}; use crate::tables::scan_diag; use filmcraft_bitstream::BitReader; +/// HEVC picture state includes several per-pixel and per-block work buffers. This permits 8K +/// pictures while rejecting dimensions that could exhaust memory during decode. +pub const MAX_LUMA_SAMPLES: u64 = 1 << 26; + +fn check_picture_size(width: u32, height: u32) -> Result<()> { + ensure!(width > 0 && height > 0 && width <= 16888 && height <= 16888, "picture size {width}x{height} out of range"); + ensure!(u64::from(width) * u64::from(height) <= MAX_LUMA_SAMPLES, "picture size {width}x{height} exceeds the decoded-frame budget"); + Ok(()) +} + /// General profile / tier / level information. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ProfileTierLevel { @@ -478,7 +488,7 @@ impl Sps { let separate_colour_plane = if chroma_format_idc == 3 { r.read_flag()? } else { false }; let width = r.read_ue()?; let height = r.read_ue()?; - ensure!(width > 0 && height > 0 && width <= 16888 && height <= 16888, "picture size {width}x{height} out of range"); + check_picture_size(width, height)?; let mut conf_win = (0, 0, 0, 0); if r.read_flag()? { conf_win = (r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?); @@ -921,3 +931,15 @@ impl Layout { self.tile_id[self.rs_to_ts[rs as usize] as usize] } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picture_size_has_an_aggregate_pixel_budget() { + check_picture_size(8192, 8192).unwrap(); + let err = check_picture_size(8192, 8193).unwrap_err(); + assert!(err.to_string().contains("decoded-frame budget")); + } +} diff --git a/crates/hevc/tests/fuzz_hostile.rs b/crates/hevc/tests/fuzz_hostile.rs new file mode 100644 index 000000000..c74400674 --- /dev/null +++ b/crates/hevc/tests/fuzz_hostile.rs @@ -0,0 +1,195 @@ +//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and +//! random mutation of small libx265 streams must never panic or run away. Skips without ffmpeg. + +#[path = "it/common/mod.rs"] +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; + +use filmcraft_hevc::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, encoder, extra encoder args, pix_fmt) +const SEEDS: &[(&str, &str, &str, &str)] = &[ + ("hevc_fz_main_b", "libx265", "-x265-params log-level=error:bframes=2:keyint=8:min-keyint=8:sao=1:amp=1", "yuv420p"), + ("hevc_fz_main10", "libx265", "-x265-params log-level=error:bframes=1:keyint=6:min-keyint=6", "yuv420p10le"), + ("hevc_fz_wpp_slices", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6:wpp=1:slices=3", "yuv420p"), + // x265 cannot encode tiles; kvazaar can, at 256 x 64 luma per tile at least (Main profile) + ("hevc_fz_tiles_kvazaar", "libkvazaar", "-s 512x128 -kvazaar-params tiles=2x2,gop=0,period=6,qp=40", "yuv420p"), + ("hevc_fz_422_10", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6", "yuv422p10le"), +]; + +fn make(ff: &std::path::Path, name: &str, enc: &str, args: &str, pix: &str) -> Option> { + let out = filmcraft_testkit::fixtures_dir("hevc").join(format!("{name}.hevc")); + let p = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let a: Vec<&str> = args.split_whitespace().collect(); + matches!( + Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", enc]) + .args(a) + .args(["-f", "hevc"]) + .arg(tmp) + .stdin(Stdio::null()) + .status(), + Ok(s) if s.success() + ) + })?; + std::fs::read(p).ok() +} + +static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + for threads in [1, 3] { + let mut dec = Decoder::with_threads(threads); + for au in common::split_access_units(d) { + let _ = dec.decode(au, 0); + } + let _ = dec.decode(d, 1); + let _ = dec.flush(); + let _ = dec.take_error(); + } + })); + assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=400` runs all of it (see [`Depth`]). +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("hevc fuzz") else { return }; + let depth = Depth::from_env(); + for &(name, enc, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, enc) { + filmcraft_testkit::skip("hevc fuzz", &format!("this ffmpeg has no {enc} encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, enc, args, pix) else { + panic!("ffmpeg has {enc} but could not encode the {name} seed"); + }; + for n in depth.truncations(seed.len()) { + run_all(&seed[..n], &format!("{name} truncate {n}")); + } + // SPS/PPS/first slice header region: every byte to a few values, every bit + for p in 0..seed.len().min(120) { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } + let mut d = seed.clone(); + d[p] = v; + run_all(&d, &format!("{name} byte {p}={v:#x}")); + } + for b in 0..8 { + if !depth.bit(p, b) { + continue; + } + let mut d = seed.clone(); + d[p] ^= 1 << b; + run_all(&d, &format!("{name} flip {p}.{b}")); + } + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(6) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + if depth.round(i) { + run_all(&d, &format!("{name} random {i}")); + } + } + } +} + +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 300 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 400 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 300, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=400 runs everything)"); + Depth { full: false, truncations: 300, rounds: 400, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} diff --git a/crates/hevc/tests/common/mod.rs b/crates/hevc/tests/it/common/mod.rs similarity index 99% rename from crates/hevc/tests/common/mod.rs rename to crates/hevc/tests/it/common/mod.rs index a8dcb8da2..61c1033f9 100644 --- a/crates/hevc/tests/common/mod.rs +++ b/crates/hevc/tests/it/common/mod.rs @@ -73,7 +73,7 @@ pub const FIXTURES: &[Fixture] = &[ fx!("odd_1918x1078", "testsrc2", 1918, 1078, 3, NOISE, 8, ["-preset", "veryfast", "-x265-params", "log-level=error"]), fx!("hd_1080p", "testsrc2", 1920, 1080, 3, "", 8, ["-preset", "fast", "-x265-params", "log-level=error"]), fx!("uhd_4k_main10", "testsrc2", 3840, 2160, 2, "", 10, ["-preset", "ultrafast", "-x265-params", "log-level=error"]), - // performance reference streams (tests/perf.rs) + // performance reference streams (tests/it/perf.rs) fx!("bench_1080p", "testsrc2", 1920, 1080, 60, "noise=alls=3:allf=t", 8, ["-preset", "medium", "-crf", "24", "-x265-params", "log-level=error"]), fx!("bench_4k", "testsrc2", 3840, 2160, 20, "noise=alls=3:allf=t", 10, ["-preset", "fast", "-crf", "26", "-x265-params", "log-level=error"]), // Apple VideoToolbox (hardware encoder, macOS only) diff --git a/crates/hevc/tests/conformance.rs b/crates/hevc/tests/it/conformance.rs similarity index 99% rename from crates/hevc/tests/conformance.rs rename to crates/hevc/tests/it/conformance.rs index 4fa8b8cbe..7b50cbaf0 100644 --- a/crates/hevc/tests/conformance.rs +++ b/crates/hevc/tests/it/conformance.rs @@ -1,7 +1,7 @@ //! Bit-exactness against ffmpeg's decoder on libx265 / VideoToolbox fixtures (skipped when ffmpeg is //! absent). -mod common; +use crate::common; macro_rules! fixture_tests { ($($name:ident),* $(,)?) => { diff --git a/crates/hevc/tests/it/main.rs b/crates/hevc/tests/it/main.rs new file mode 100644 index 000000000..be033f189 --- /dev/null +++ b/crates/hevc/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod conformance; +mod perf; +mod robustness; diff --git a/crates/hevc/tests/perf.rs b/crates/hevc/tests/it/perf.rs similarity index 91% rename from crates/hevc/tests/perf.rs rename to crates/hevc/tests/it/perf.rs index 4091166b6..ab8144a12 100644 --- a/crates/hevc/tests/perf.rs +++ b/crates/hevc/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode speed (ignored by default; run with -//! `cargo test --release -p filmcraft-hevc --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-hevc --test it perf:: -- --ignored --nocapture`). -mod common; +use crate::common; use filmcraft_hevc::Decoder; use std::time::Instant; diff --git a/crates/hevc/tests/robustness.rs b/crates/hevc/tests/it/robustness.rs similarity index 99% rename from crates/hevc/tests/robustness.rs rename to crates/hevc/tests/it/robustness.rs index 075d5fde2..50f35b0d4 100644 --- a/crates/hevc/tests/robustness.rs +++ b/crates/hevc/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! hvcC / length-prefixed input and resilience against damaged streams. -mod common; +use crate::common; use filmcraft_bitstream::annexb_nals; use filmcraft_hevc::Decoder; diff --git a/crates/hevc/tools/extract_tables.py b/crates/hevc/tools/extract_tables.py index 8b9fa3979..8b7d7d35e 100644 --- a/crates/hevc/tools/extract_tables.py +++ b/crates/hevc/tools/extract_tables.py @@ -13,8 +13,18 @@ initType following Table 9-4, default scaling lists (Table 7-6), intraPredAngle """ import re import sys +from pathlib import Path -text = open(sys.argv[1], encoding="utf-8").read().replace("−", "-").replace("–", "-") + +def _spec_path(arg): + """Resolve a CLI path argument and require it to be an existing regular file.""" + path = Path(arg).resolve() + if not path.is_file(): + sys.exit(f"not a readable file: {arg}") + return path + + +text = open(_spec_path(sys.argv[1]), encoding="utf-8").read().replace("−", "-").replace("–", "-") lines = text.split("\n") @@ -34,7 +44,7 @@ def body_after(title_regex, stop_regex=r"^\s*(Table \d+-\d+ - |\d+\s+Rec\. ITU|R def init_values(table): vals = [] for l in body_after(r"Table %s - Values of initValue" % re.escape(table)): - m = re.match(r"^\s*initValue\s+(.*)$", l) + m = re.match(r"^\s*initValue\s+(\S.*)$", l) if m: vals += [int(v) for v in m.group(1).split()] return vals @@ -108,7 +118,7 @@ w("") # Table 7-6: default 8x8 scaling lists (intra matrixId 0..2, inter 3..5) intra, inter = [], [] for l in body_after(r"Table 7-6 - Specification of default values of ScalingList"): - m = re.match(r"^\s*ScalingList\[ 1\.\.3 \]\[ (0\.\.2|3\.\.5) \]\[ i \]\s+(.*)$", l) + m = re.match(r"^\s*ScalingList\[ 1\.\.3 \]\[ (0\.\.2|3\.\.5) \]\[ i \]\s+(\S.*)$", l) if m: (intra if m.group(1) == "0..2" else inter).extend(int(v) for v in m.group(2).split()) assert len(intra) == 64 and len(inter) == 64 diff --git a/crates/interchange/README.md b/crates/interchange/README.md index edaff851f..b0cd33577 100644 --- a/crates/interchange/README.md +++ b/crates/interchange/README.md @@ -25,7 +25,7 @@ read off files it exports, never from the application itself. The other formats' editions are listed in the module docs (`edl`, `fcp7`, `fcpxml`, `otio`, `ale`). **Invariant — Unicode media URLs:** file URL prefix probes must not panic when their byte boundary -falls inside a UTF-8 character. Timeline importers use the shared converter; `tests/file_urls.rs` +falls inside a UTF-8 character. Timeline importers use the shared converter; `tests/it/file_urls.rs` checks expected Unicode paths through the public converter, resolver and EDL importer. ## AAF and OMF @@ -85,7 +85,7 @@ OMF carries no video at all. ## Tests -- `tests/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps, +- `tests/it/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps, two-sided and one-sided transitions, links, markers, start timecodes, constant and keyframed gain, media markers and timecode); 512-byte sectors; breakout to mono; embedded trimmed audio (sample data and source offsets); separate consolidated files with a video mixdown; OMF round @@ -93,7 +93,7 @@ OMF carries no video at all. random corruption never panic. Nested sequences: AAF round trips as compositions (nests in nests, a nest used twice, another frame and sample rate, breakout to mono), OMF with the rendered sound, the audio needs of nests, a sequence inside itself. -- `tests/uncarried_clips.rs`: every format's export report names each graphic clip and adjustment +- `tests/it/uncarried_clips.rs`: every format's export report names each graphic clip and adjustment layer once, as a warning; a sequence without them reports nothing; what a re-import gives back matches the report; the FCP7 XML import names a clip item whose file is not defined. - `src/aaf/tests.rs`: written files checked against the required properties of every class diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs index 2823ff755..be47a4317 100644 --- a/crates/interchange/src/common.rs +++ b/crates/interchange/src/common.rs @@ -13,6 +13,11 @@ use filmcraft_time::{FrameRate, Tick}; use crate::{Imported, Report}; +/// A timeline import may not materialise an unbounded number of sparse track +/// labels. This remains well above common editorial timelines while keeping +/// hostile lane/channel numbers from allocating every intervening track. +pub(crate) const MAX_IMPORTED_TRACKS: usize = 256; + // --------------------------------------------------------------------------------------------- // Paths and URLs // --------------------------------------------------------------------------------------------- @@ -65,7 +70,9 @@ pub fn file_url_to_path(url: &str) -> String { return url.to_string(); } let rest = &url[7..]; - let rest = if rest.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest }; + let is_localhost = + rest.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("localhost")) && rest.get(9..).is_some_and(|after| after.is_empty() || after.starts_with('/')); + let rest = if is_localhost { &rest[9..] } else { rest }; let decoded = percent_decode(rest); // file:///C:/x -> C:/x if decoded.len() >= 3 && decoded.starts_with('/') && is_windows_abs(&decoded[1..]) { @@ -408,12 +415,18 @@ impl Builder { Track::new(id, kind, name) } - pub fn ensure_tracks(&mut self, seq: &mut Sequence, kind: TrackKind, n: usize) { + /// Ensure `n` tracks of one kind exist. Returns false when an imported + /// label would exceed the supported per-kind track count. + pub fn ensure_tracks(&mut self, seq: &mut Sequence, kind: TrackKind, n: usize) -> bool { + if n > MAX_IMPORTED_TRACKS { + return false; + } while seq.tracks(kind).len() < n { let i = seq.tracks(kind).len(); let t = self.track(kind, i); seq.tracks_mut(kind).push(t); } + true } pub fn link_id(&mut self) -> u64 { @@ -463,15 +476,17 @@ impl Builder { /// Place `item` on the first track at or after `preferred` where it does not overlap anything, /// creating tracks as needed. Returns the track index. - pub fn place(&mut self, seq: &mut Sequence, kind: TrackKind, preferred: usize, item: TrackItem) -> usize { + pub fn place(&mut self, seq: &mut Sequence, kind: TrackKind, preferred: usize, item: TrackItem) -> Option { let mut i = preferred; loop { - self.ensure_tracks(seq, kind, i + 1); + if i >= MAX_IMPORTED_TRACKS || !self.ensure_tracks(seq, kind, i + 1) { + return None; + } let t = &mut seq.tracks_mut(kind)[i]; if !t.items.iter().any(|o| o.start < item.end() && item.start < o.end()) { t.items.push(item); t.sort(); - return i; + return Some(i); } i += 1; } @@ -692,6 +707,14 @@ mod tests { } } + #[test] + fn file_url_localhost_prefix_host_is_kept() { + assert_eq!(file_url_to_path("file://localhost-nas/share/a.mov"), "//localhost-nas/share/a.mov"); + assert_eq!(file_url_to_path("file://LOCALHOSTX/a"), "//LOCALHOSTX/a"); + assert_eq!(file_url_to_path("file://localhost/a/b"), "/a/b"); + assert_eq!(file_url_to_path("file://LocalHost/a"), "/a"); + } + #[test] fn paths() { assert_eq!(resolve_path("media/a.mov", Some("/proj")), "/proj/media/a.mov"); @@ -713,4 +736,14 @@ mod tests { assert_eq!(rate_from_f64(48000.0), FrameRate::new(48000, 1)); assert_eq!(frames_round(FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5) + Tick(10)), 5); } + + #[test] + fn imported_tracks_are_bounded() { + let mut b = Builder::new("tracks"); + let mut seq = empty_sequence(Default::default()); + assert!(b.ensure_tracks(&mut seq, TrackKind::Video, MAX_IMPORTED_TRACKS)); + assert_eq!(seq.video_tracks.len(), MAX_IMPORTED_TRACKS); + assert!(!b.ensure_tracks(&mut seq, TrackKind::Video, MAX_IMPORTED_TRACKS + 1)); + assert_eq!(seq.video_tracks.len(), MAX_IMPORTED_TRACKS); + } } diff --git a/crates/interchange/src/comp.rs b/crates/interchange/src/comp.rs index 2ade96bcc..32c8698f2 100644 --- a/crates/interchange/src/comp.rs +++ b/crates/interchange/src/comp.rs @@ -698,7 +698,10 @@ pub(crate) fn to_project(doc: Document, opts: &ImportOptions, report: &mut Repor None => groups.push((k, vec![i])), } } - let doc_stem = opts.name.clone().unwrap_or_else(|| "Imported".into()); + // This becomes a directory name for embedded AAF/OMF audio. `ImportOptions::name` normally + // comes from the document filename, where backslashes are ordinary Unix filename characters; + // `resolve_path` deliberately treats them as separators, so retain only one safe component. + let doc_stem = safe_path_component(opts.name.as_deref().unwrap_or("Imported")); let mut used_paths = std::collections::HashSet::new(); for ((_, path), members) in &groups { let srcs: Vec<&Source> = members.iter().map(|&i| &doc.sources[i]).collect(); @@ -788,6 +791,13 @@ pub(crate) fn to_project(doc: Document, opts: &ImportOptions, report: &mut Repor Ok((b.finish(), extracted)) } +/// A display name used as a generated path component, never a caller-selected path fragment. +fn safe_path_component(name: &str) -> String { + let safe: String = name.chars().map(|c| if c.is_alphanumeric() || " -_.".contains(c) { c } else { '_' }).collect(); + let safe = safe.trim(); + if safe.is_empty() || matches!(safe, "." | "..") { "Imported".into() } else { safe.into() } +} + fn build_sequence( b: &mut Builder, comp: &Composition, @@ -1004,3 +1014,15 @@ pub(crate) fn ticks_to_units(t: Tick, num: i64, den: i64) -> (i64, bool) { let n = q + if r * 2 >= d { 1 } else { 0 }; (n.clamp(i64::MIN as i128, i64::MAX as i128) as i64, r == 0) } + +#[cfg(test)] +mod path_tests { + use super::safe_path_component; + + #[test] + fn generated_embedded_audio_folder_is_one_safe_component() { + for (input, expected) in [(r"..\escape", ".._escape"), ("../escape", ".._escape"), ("..", "Imported"), ("", "Imported")] { + assert_eq!(safe_path_component(input), expected, "{input:?}"); + } + } +} diff --git a/crates/interchange/src/edl.rs b/crates/interchange/src/edl.rs index ecee03914..5f114254c 100644 --- a/crates/interchange/src/edl.rs +++ b/crates/interchange/src/edl.rs @@ -375,7 +375,9 @@ impl Ctx<'_> { } } let id = ti.id; - self.b.ensure_tracks(&mut self.seq, kind, idx + 1); + if !self.b.ensure_tracks(&mut self.seq, kind, idx + 1) { + continue; + } let mut next = self.b.p.next_id; let track = &mut self.seq.tracks_mut(kind)[idx]; overwrite(track, ti, &mut || { @@ -487,7 +489,9 @@ fn import_event(ctx: &mut Ctx, ev: &Event, report: &mut Report) { let chans: BTreeSet = l2.chans.iter().copied().collect(); for ch in chans { let (kind, idx) = ctx.track_index(ch, 0); - ctx.b.ensure_tracks(&mut ctx.seq, kind, idx + 1); + if !ctx.b.ensure_tracks(&mut ctx.seq, kind, idx + 1) { + continue; + } let to = placed.iter().find(|(k, i, _)| *k == kind && *i == idx).map(|p| p.2); let track = &mut ctx.seq.tracks_mut(kind)[idx]; let from = if from_black { None } else { track.items.iter().find(|i| i.end() == t0).map(|i| i.id) }; diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index e0e9075d9..2bc67d9e7 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -27,9 +27,9 @@ use filmcraft_time::{FrameRate, Tick, format_timecode_frames}; use roxmltree::Node; use crate::common::{ - Builder, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, frames_round, gain_to_db, item_media, item_path, kind_from_ext, label_from, on_frame, - param, param_modified, path_to_file_url, rate_from_timebase, resolve_path, set_param, settings_for, standard_effects, timebase, transition_effect, - transition_name, unscaled, + Builder, MAX_IMPORTED_TRACKS, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, frames_round, gain_to_db, item_media, item_path, kind_from_ext, + label_from, on_frame, param, param_modified, path_to_file_url, rate_from_timebase, resolve_path, set_param, settings_for, standard_effects, timebase, + transition_effect, transition_name, unscaled, }; use crate::xml::{XmlWriter, bool_str, child, child_bool, child_f64, child_i64, child_text, children, elements, num, path_text}; use crate::{Error, ExportOptions, Format, ImportOptions, Imported, Report, Result}; @@ -343,7 +343,10 @@ impl<'a, 'i> Imp<'a, 'i, '_> { for (kind, tag) in [(TrackKind::Video, "video"), (TrackKind::Audio, "audio")] { let Some(m) = media.and_then(|m| child(m, tag)) else { continue }; for (ti, tn) in children(m, "track").enumerate() { - self.b.ensure_tracks(&mut seq, kind, ti + 1); + if !self.b.ensure_tracks(&mut seq, kind, ti + 1) { + self.report.warn(format!("FCP7 {kind:?} tracks beyond {MAX_IMPORTED_TRACKS} were skipped")); + break; + } self.track(&mut seq, id, kind, ti, tn, rate, (w, h)); } } @@ -378,7 +381,9 @@ impl<'a, 'i> Imp<'a, 'i, '_> { let t = &mut seq.tracks_mut(kind)[ti]; if t.items.iter().any(|o| o.start < ti_item.end() && ti_item.start < o.end()) { self.report.warn("overlapping clip items on one track; the later one was moved to a new track"); - self.b.place(seq, kind, ti + 1, ti_item); + if self.b.place(seq, kind, ti + 1, ti_item).is_none() { + self.report.warn(format!("FCP7 {kind:?} track exceeds the {MAX_IMPORTED_TRACKS}-track import limit; clip was skipped")); + } } else { t.items.push(ti_item); } diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs index e231fff5e..438e22fbc 100644 --- a/crates/interchange/src/fcpxml.rs +++ b/crates/interchange/src/fcpxml.rs @@ -26,8 +26,8 @@ use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use roxmltree::Node; use crate::common::{ - Builder, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, gain_to_db, generator_of, item_media, item_path, kind_from_ext, param, - param_modified, path_to_file_url, resolve_path, scaled, set_param, settings_for, standard_effects, transition_effect, transition_name, + Builder, MAX_IMPORTED_TRACKS, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, gain_to_db, generator_of, item_media, item_path, kind_from_ext, + param, param_modified, path_to_file_url, resolve_path, scaled, set_param, settings_for, standard_effects, transition_effect, transition_name, }; use crate::xml::{child, children, elements, num}; use crate::{Error, ExportOptions, Format, ImportOptions, Imported, Report, Result}; @@ -437,14 +437,17 @@ impl<'a, 'i> Imp<'a, 'i, '_> { TrackKind::Video => lane.max(0) as usize, TrackKind::Audio => { if lane < 0 { - (-lane - 1) as usize + lane.unsigned_abs() as usize - 1 } else { lane as usize } } }; let id = ti.id; - self.b.place(seq, kind, pref, ti); + if self.b.place(seq, kind, pref, ti).is_none() { + self.report.warn(format!("FCPXML {kind:?} lane exceeds the {MAX_IMPORTED_TRACKS}-track import limit; clip was skipped")); + continue; + } placed.push((kind, id)); if kind == TrackKind::Video { vclip = Some(id); @@ -462,7 +465,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { let Some(l) = c.attribute("lane").and_then(|l| l.parse::().ok()) else { continue }; let off = attr_time(c, "offset").unwrap_or(Tick::ZERO); let ct0 = t0 + (off - local_start); - let clane = if lane > 0 && l > 0 { lane + l } else { l }; + let clane = if lane > 0 && l > 0 { lane.saturating_add(l) } else { l }; if c.has_tag_name("spine") { // Secondary storyline: children offsets are in the storyline's local time. let first = elements(c).filter(|x| !x.has_tag_name("transition")).find_map(|x| attr_time(x, "offset")).unwrap_or(Tick::ZERO); @@ -673,8 +676,8 @@ pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: out.push_str(r); } out.push_str(" \n"); - out.push_str(&format!(" \n \n", crate::xml::escape(&p.name))); - out.push_str(&format!(" \n", crate::xml::escape(&name))); + out.push_str(&format!(" \n \n", crate::xml::escape_attr(&p.name))); + out.push_str(&format!(" \n", crate::xml::escape_attr(&name))); out.push_str(&body); out.push_str(" \n \n \n\n"); Ok(out) @@ -728,7 +731,7 @@ impl Exp<'_, '_> { let start = m.info.start_timecode.map(|f| mrate.tick_of(f)).unwrap_or(Tick::ZERO); let mut a = format!( " { } let path = item_path(self.p, item).unwrap_or(""); let src = if crate::common::is_absolute(path) { path_to_file_url(path, false) } else { path.to_string() }; - a.push_str(&format!(">\n \n \n", crate::xml::escape(&src))); + a.push_str(&format!(">\n \n \n", crate::xml::escape_attr(&src))); self.res_out.push(a); self.assets.insert(item, Res { id: id.clone() }); let _ = file_name; @@ -764,7 +767,7 @@ impl Exp<'_, '_> { let body = self.sequence_xml(item, &fmt, 3); self.building.pop(); let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default(); - self.res_out.push(format!(" \n{body} \n", crate::xml::escape(&name))); + self.res_out.push(format!(" \n{body} \n", crate::xml::escape_attr(&name))); id } @@ -773,7 +776,11 @@ impl Exp<'_, '_> { return r.id.clone(); } let id = self.rid(); - self.res_out.push(format!(" \n", crate::xml::escape(name), crate::xml::escape(id_hint))); + self.res_out.push(format!( + " \n", + crate::xml::escape_attr(name), + crate::xml::escape_attr(id_hint) + )); self.effects.insert(name.to_string(), Res { id: id.clone() }); id } @@ -934,7 +941,7 @@ impl Exp<'_, '_> { "", fmt_time(tc0 + tr.start, rate), fmt_time(tr.duration, rate), - n = crate::xml::escape(&name) + n = crate::xml::escape_attr(&name) ) } @@ -946,7 +953,7 @@ impl Exp<'_, '_> { let is_seq = matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_))); let generator = generator_of(self.p, base).cloned(); let lane_attr = lane.map(|l| format!(" lane=\"{l}\"")).unwrap_or_default(); - let name = crate::xml::escape(&it.name); + let name = crate::xml::escape_attr(&it.name); if let Some(g) = generator { self.report.warn(format!("generator \"{}\" is not supported in FCPXML; written as a gap", g.label())); return El { @@ -1043,7 +1050,7 @@ impl Exp<'_, '_> { let n = e.def().map(|d| d.name).unwrap_or(&e.effect).to_string(); let r = self.effect(&n, &e.effect); let tag = if kind == TrackKind::Video { "filter-video" } else { "filter-audio" }; - let mut s = format!("<{tag} ref=\"{r}\" name=\"{}\"", crate::xml::escape(&n)); + let mut s = format!("<{tag} ref=\"{r}\" name=\"{}\"", crate::xml::escape_attr(&n)); if !e.enabled { s.push_str(" enabled=\"0\""); } @@ -1169,8 +1176,8 @@ fn kparam_xml( fn marker_xml(m: &Marker, local: Tick, rate: FrameRate) -> String { let tag = if m.kind == MarkerKind::Chapter { "chapter-marker" } else { "marker" }; let dur = if m.duration > Tick::ZERO { m.duration } else { rate.frame_duration() }; - let note = if m.comment.is_empty() { String::new() } else { format!(" note=\"{}\"", crate::xml::escape(&m.comment)) }; - format!("<{tag} start=\"{}\" duration=\"{}\" value=\"{}\"{note}/>", fmt_time(local, rate), fmt_time(dur, rate), crate::xml::escape(&m.name)) + let note = if m.comment.is_empty() { String::new() } else { format!(" note=\"{}\"", crate::xml::escape_attr(&m.comment)) }; + format!("<{tag} start=\"{}\" duration=\"{}\" value=\"{}\"{note}/>", fmt_time(local, rate), fmt_time(dur, rate), crate::xml::escape_attr(&m.name)) } #[cfg(test)] diff --git a/crates/interchange/src/xml.rs b/crates/interchange/src/xml.rs index 6bd1c51dc..4d227be49 100644 --- a/crates/interchange/src/xml.rs +++ b/crates/interchange/src/xml.rs @@ -20,6 +20,21 @@ pub(crate) fn escape(s: &str) -> String { o } +/// Like `escape`, but also encodes whitespace that attribute-value +/// normalization would otherwise turn into spaces. +pub(crate) fn escape_attr(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\n' => o.push_str(" "), + '\r' => o.push_str(" "), + '\t' => o.push_str(" "), + c => o.push_str(&escape(c.encode_utf8(&mut [0; 4]))), + } + } + o +} + /// Pretty-printing XML writer (two-space indent, one element per line). pub(crate) struct XmlWriter { out: String, @@ -46,7 +61,7 @@ impl XmlWriter { self.out.push('<'); self.out.push_str(name); for (k, v) in attrs { - let _ = write!(self.out, " {k}=\"{}\"", escape(v)); + let _ = write!(self.out, " {k}=\"{}\"", escape_attr(v)); } } @@ -169,4 +184,16 @@ mod tests { assert_eq!(num(-0.0), "0"); assert_eq!(num(0.1 + 0.2).parse::().unwrap(), 0.1 + 0.2); } + + #[test] + fn attr_whitespace_roundtrip() { + let mut w = XmlWriter::new(None); + w.open("a", &[("name", "x\ny\tz\r")]); + w.text("b", "a\nb"); + let s = w.finish(); + let d = parse(&s).unwrap(); + let a = d.root_element(); + assert_eq!(a.attribute("name"), Some("x\ny\tz\r")); + assert_eq!(child(a, "b").and_then(|b| b.text()), Some("a\nb")); + } } diff --git a/crates/interchange/tests/aaf_omf.rs b/crates/interchange/tests/it/aaf_omf.rs similarity index 99% rename from crates/interchange/tests/aaf_omf.rs rename to crates/interchange/tests/it/aaf_omf.rs index 3e16492f2..4a065677c 100644 --- a/crates/interchange/tests/aaf_omf.rs +++ b/crates/interchange/tests/it/aaf_omf.rs @@ -1,6 +1,6 @@ //! AAF and OMF: export → import round trips (no external oracle exists for these formats). -mod support; +use crate::support; use filmcraft_interchange::aaf::{self, AafOptions}; use filmcraft_interchange::essence::{AudioEssence, EssenceData, EssenceKey, MediaOptions, MixdownVideo, NeedOptions, NestNeeds, audio_needs}; diff --git a/crates/interchange/tests/api.rs b/crates/interchange/tests/it/api.rs similarity index 99% rename from crates/interchange/tests/api.rs rename to crates/interchange/tests/it/api.rs index 6a212793c..7d19eb3a9 100644 --- a/crates/interchange/tests/api.rs +++ b/crates/interchange/tests/it/api.rs @@ -1,6 +1,6 @@ //! Crate API: detection, merging fragments into a project, timecode rebasing. -mod support; +use crate::support; use std::collections::HashSet; diff --git a/crates/interchange/tests/edl.rs b/crates/interchange/tests/it/edl.rs similarity index 99% rename from crates/interchange/tests/edl.rs rename to crates/interchange/tests/it/edl.rs index 14100d146..db7369589 100644 --- a/crates/interchange/tests/edl.rs +++ b/crates/interchange/tests/it/edl.rs @@ -1,6 +1,6 @@ //! CMX 3600 EDL: golden documents (hand-written from the public CMX 3600 conventions) and round trips. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, ImportOptions, ReelMode, detect, export, export_edl_per_track, import, import_with}; use filmcraft_project::{ItemKind, MediaRef, TrackKind, TransitionAlign}; diff --git a/crates/interchange/tests/fcp7.rs b/crates/interchange/tests/it/fcp7.rs similarity index 99% rename from crates/interchange/tests/fcp7.rs rename to crates/interchange/tests/it/fcp7.rs index aad1d5f0e..663ce7337 100644 --- a/crates/interchange/tests/fcp7.rs +++ b/crates/interchange/tests/it/fcp7.rs @@ -1,7 +1,7 @@ //! Final Cut Pro 7 XML (xmeml): a hand-written Premiere-style document (written from the public //! xmeml element descriptions) and export → import round trips. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, Imported, detect, export, import}; use filmcraft_media::Generator; diff --git a/crates/interchange/tests/fcpxml.rs b/crates/interchange/tests/it/fcpxml.rs similarity index 97% rename from crates/interchange/tests/fcpxml.rs rename to crates/interchange/tests/it/fcpxml.rs index 12892da57..2bf6f3c46 100644 --- a/crates/interchange/tests/fcpxml.rs +++ b/crates/interchange/tests/it/fcpxml.rs @@ -1,7 +1,7 @@ //! FCPXML: a hand-written document (from the public FCPXML element reference) with connected //! clips, a transition, a retimed clip, a gap and a compound clip; plus export → import round trips. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, FcpxmlVersion, Format, detect, import}; use filmcraft_project::{ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind, TransitionAlign}; @@ -163,6 +163,16 @@ fn golden_connected_clips() { assert!(matches!(&m.media, MediaRef::File { path } if path == "/proj/boats.mov")); } +#[test] +fn hostile_lane_numbers_do_not_create_sparse_tracks() { + let d = doc().replace("lane=\"-1\"", "lane=\"-2147483648\"").replace("lane=\"1\"", "lane=\"2147483647\""); + let (imp, rep) = import(d.as_bytes(), Format::Fcpxml, Some("/proj")).unwrap(); + let s = imp.project.sequence(only_seq(&imp)).unwrap(); + assert!(s.video_tracks.len() <= 256); + assert!(s.audio_tracks.len() <= 256); + assert!(rep.has_warnings()); +} + fn project() -> (Project, filmcraft_project::ItemId) { let r = FrameRate::FPS_29_97; let mut p = Project::new("Lib"); diff --git a/crates/interchange/tests/file_urls.rs b/crates/interchange/tests/it/file_urls.rs similarity index 100% rename from crates/interchange/tests/file_urls.rs rename to crates/interchange/tests/it/file_urls.rs diff --git a/crates/interchange/tests/it/main.rs b/crates/interchange/tests/it/main.rs new file mode 100644 index 000000000..81da996e9 --- /dev/null +++ b/crates/interchange/tests/it/main.rs @@ -0,0 +1,12 @@ +mod aaf_omf; +mod api; +mod edl; +mod fcp7; +mod fcpxml; +mod file_urls; +mod media_marker_export; +mod nested_sequence_markers; +mod otio; +mod point_markers; +mod support; +mod uncarried_clips; diff --git a/crates/interchange/tests/media_marker_export.rs b/crates/interchange/tests/it/media_marker_export.rs similarity index 99% rename from crates/interchange/tests/media_marker_export.rs rename to crates/interchange/tests/it/media_marker_export.rs index 752cc8b5d..e209b0693 100644 --- a/crates/interchange/tests/media_marker_export.rs +++ b/crates/interchange/tests/it/media_marker_export.rs @@ -1,6 +1,6 @@ //! Source-media (master clip) markers must survive FCP7 XML export for xmeml 4 and 5. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, export, import}; use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; diff --git a/crates/interchange/tests/nested_sequence_markers.rs b/crates/interchange/tests/it/nested_sequence_markers.rs similarity index 99% rename from crates/interchange/tests/nested_sequence_markers.rs rename to crates/interchange/tests/it/nested_sequence_markers.rs index 9f69b755e..6e4eca92b 100644 --- a/crates/interchange/tests/nested_sequence_markers.rs +++ b/crates/interchange/tests/it/nested_sequence_markers.rs @@ -4,7 +4,7 @@ //! sequence's markers when the using clip has none, and must not put clip instance markers on the //! nested sequence. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format}; use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; diff --git a/crates/interchange/tests/otio.rs b/crates/interchange/tests/it/otio.rs similarity index 99% rename from crates/interchange/tests/otio.rs rename to crates/interchange/tests/it/otio.rs index 0b952add4..857de1e79 100644 --- a/crates/interchange/tests/otio.rs +++ b/crates/interchange/tests/it/otio.rs @@ -1,7 +1,7 @@ //! OpenTimelineIO: a hand-written plain OTIO document (from the public OTIO schema docs) with gaps, //! transitions, markers and legacy `Clip.1`; lossless round trips through `metadata.filmcraft`. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, detect, import}; use filmcraft_media::Generator; diff --git a/crates/interchange/tests/point_markers.rs b/crates/interchange/tests/it/point_markers.rs similarity index 99% rename from crates/interchange/tests/point_markers.rs rename to crates/interchange/tests/it/point_markers.rs index e0ffb5616..ba96cec58 100644 --- a/crates/interchange/tests/point_markers.rs +++ b/crates/interchange/tests/it/point_markers.rs @@ -1,6 +1,6 @@ //! FCPXML point markers: a one-frame `` is a FilmCraft point (duration 0), not a range. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, import}; use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, Project, TrackKind}; diff --git a/crates/interchange/tests/support/mod.rs b/crates/interchange/tests/it/support/mod.rs similarity index 100% rename from crates/interchange/tests/support/mod.rs rename to crates/interchange/tests/it/support/mod.rs diff --git a/crates/interchange/tests/uncarried_clips.rs b/crates/interchange/tests/it/uncarried_clips.rs similarity index 99% rename from crates/interchange/tests/uncarried_clips.rs rename to crates/interchange/tests/it/uncarried_clips.rs index 2f0902e26..136802897 100644 --- a/crates/interchange/tests/uncarried_clips.rs +++ b/crates/interchange/tests/it/uncarried_clips.rs @@ -2,7 +2,7 @@ //! export used to lose them without a word (FCP7 XML, OTIO, EDL) or with a line that named //! nothing (FCPXML, AAF): every such clip is now named in the export report. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, Report, export, import}; use filmcraft_project::{ItemId, ItemKind, Label, ParamValue, Project, TrackKind, find_effect}; diff --git a/crates/isobmff/Cargo.toml b/crates/isobmff/Cargo.toml index c9b848fa0..b24ab1a1a 100644 --- a/crates/isobmff/Cargo.toml +++ b/crates/isobmff/Cargo.toml @@ -10,8 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] -filmcraft-bitstream = { workspace = true } -thiserror = { workspace = true } +craft-media = { workspace = true, features = ["isobmff"] } [dev-dependencies] filmcraft-testkit = { workspace = true } diff --git a/crates/isobmff/README.md b/crates/isobmff/README.md index 47c31bb43..39ba2ffd6 100644 --- a/crates/isobmff/README.md +++ b/crates/isobmff/README.md @@ -1,146 +1,3 @@ # filmcraft-isobmff -A clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) demuxer and muxer, -written from the public specifications. Layer L0: it depends only on `filmcraft-bitstream` and `thiserror`, -has no `unsafe`, and builds for `wasm32-unknown-unknown`. - -## Demuxing - -```rust -use filmcraft_isobmff::{open, TrackKind}; - -let file = std::fs::File::open("clip.mov")?; -let mp4 = open(&file)?; // any ByteSource: &[u8], Vec, Arc<[u8]>, File, ... -let v = mp4.track_of_kind(TrackKind::Video).unwrap(); -let track = &mp4.tracks[v]; -let i = track.sample_at_presentation_time(1000).unwrap(); // track timescale, edit-adjusted -let key = track.sync_sample_before(i); // decode from here -let bytes = mp4.read_sample(&file, v, key)?; // raw sample (AVCC/HVCC length-prefixed NALs, ...) -``` - -- `ByteSource` (`len`, `read_at(offset, buf)`) is the input abstraction. Implementations exist for `[u8]`, - `Vec`, `&T`, `Arc`, `Box`, and `std::fs::File` on non-wasm targets. -- `open` reads only top-level box headers, `ftyp`, `moov` and every `moof`. It never reads `mdat`. - The one exception is the first sample of a `tmcd` track, which it reads (4 bytes) to get the timecode - start frame. -- `Mp4File`: brands, `is_quicktime`, movie `timescale`/`duration`, `tracks`, `metadata`, `fragmented`, - `fragment_count`. -- `Track`: `id`, `kind` (Video/Audio/Timecode/Subtitle/Other), `handler`, `timescale`, `duration`, - `language`, `tkhd` fields (size, matrix, volume, enabled), `entries: Vec`, `samples`, - `edits`, `edit_offset`, `references` (`tref`), `sdtp`, `sample_groups` (`sbgp`+`sgpd`, raw), `pcm_chunked`. - Helpers: `codec()`, `video()`, `audio()`, `presentation_pts(i)`, `sample_at_pts(pts)`, - `sample_at_presentation_time(t)`, `sync_sample_before(i)`, `sync_samples()`. -- `Sample`: `offset`, `size`, `dts`, `pts` (dts + ctts), `duration`, `is_sync` (all samples are sync when - `stss` is absent), `description_index`. All times are raw media time in the track timescale. -- **Edit lists:** `edits` holds the `elst` entries unchanged. `edit_offset` is - `leading empty edits (rescaled to media timescale) − first non-empty edit's media_time`, so - `presentation time = pts + edit_offset`. This matches ffprobe's timestamps for the common cases - (B-frame delay, AAC priming, empty-edit offsets). Multi-segment edits, dwells and rate changes are exposed - raw for the caller to interpret. -- **Uncompressed PCM** (`lpcm`, `sowt`, `twos`, `in24`, `in32`, `fl32`, `fl64`, `raw `, `ipcm`, `fpcm`) is - merged to one `Sample` per chunk (`pcm_chunked = true`), with `duration` in frames. This avoids one - table entry per audio frame. - -### Parsed boxes -`ftyp`, `moov/mvhd`, `trak/tkhd`, `edts/elst` (v0/v1), `tref`, `mdia/mdhd` (v0/v1, ISO and Mac language -codes), `hdlr` (ISO and Pascal names), `minf/stbl`: `stsd`, `stts`, `ctts` (v0/v1, read as signed), `stss`, -`stsc`, `stsz`/`stz2` (4/8/16-bit), `stco`/`co64`, `sdtp`, `sbgp`/`sgpd`. Also `mvex/trex`, `moof/traf/tfhd/tfdt/trun` -(every `tfhd` base-offset mode and multiple `moof`s), and `udta` metadata (QuickTime `©xxx` atoms, -`meta/ilst`, and `meta/keys` (mdta)). `uuid`, `free`, `skip`, `wide`, `sidx` and `mfra` are skipped. -Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are supported. - -### Sample entries → `CodecConfig` -| Format | Config | -|---|---| -| `avc1`/`avc3` + `avcC` | `Avc(AvcConfig)`: profile, level, length size, SPS/PPS, extension bytes | -| `hvc1`/`hev1` + `hvcC` | `Hevc(HevcConfig)`: all header fields plus NAL arrays (`vps()/sps()/pps()`) | -| `av01` + `av1C` | `Av1(Av1Config)` | -| `vp09` + `vpcC` | `Vp9(VpcConfig)` | -| `apch apcn apcs apco ap4h ap4x` | `ProRes { fourcc }` | -| `jpeg mjpa mjpb` | `Jpeg { fourcc }` | -| `AVdn AVdh` | `Dnx { fourcc }` | -| `mp4a` + `esds` (in `wave` too) | `Aac(AacConfig)`: ASC bytes + decoded object type/rate/channels; `Mp3` for OTI 0x69/0x6B | -| `.mp3`/`.mp2` | `Mp3`: MPEG-1/2 audio of any layer (each frame header names it) | -| PCM fourccs | `Pcm(PcmConfig)`: bits, float, endianness (`enda`, lpcm flags, `pcmC`), channels, rate | -| `alac` | `Alac { cookie }` | -| `Opus` + `dOps` | `Opus(OpusConfig)` | -| `fLaC` + `dfLa` | `Flac(FlacConfig)` (STREAMINFO decoded) | -| `ac-3`/`ec-3` | `Ac3 { dac3 }` / `Eac3 { dec3 }` | -| `tmcd` | `Timecode(TimecodeConfig)`: flags (drop frame), timescale, frame duration, fps, start frame, reel name; `format_frame()` | -| anything else | `Unknown { fourcc, raw }` | - -Video entries also parse `colr` (`nclx`/`nclc`/ICC), `pasp`, `clap`, `fiel`, `gama` and `btrt` into -`VideoParams`. The MP4 source in `filmcraft-codecs` crops every frame to `clap`'s clean aperture -before the track's display rotation, and reports that size. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`. - -### Robustness -The parser does not panic on malformed input; every error comes back as an `Error`. Table counts are -checked against the bytes in their box before anything is allocated. A constant-size `stsz` count is -clamped to what fits in the file. `trun` counts are checked, and each track is capped at 2²⁶ samples. -`moov`/`moof` are capped at 1 GiB. Compressed movie headers (`cmov`) are rejected with -`Error::Unsupported`. - -## Muxing - -```rust -use filmcraft_isobmff::*; -let mut w = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4))?; -let v = w.add_track(TrackConfig::new(SampleEntry::avc(avcc, 1920, 1080), 90_000))?; -let mut acfg = TrackConfig::new(SampleEntry::aac(asc, 2, 48_000), 48_000); -acfg.media_start = Some(1024); // AAC encoder delay → edit list -let a = w.add_track(acfg)?; -w.write_sample(v, WriteSample { data: &au, duration: 3000, composition_offset: 6000, is_sync: true })?; -// ... interleave samples in decode order ... -let bytes = w.finish_faststart()?.into_inner(); // or finish() for moov-at-end -``` - -- `Mp4Writer` streams samples into a single `mdat`. `finish()` appends `moov`. - `finish_faststart()` (needs `W: Read + Write + Seek`) moves the media data forward in place and writes - `moov` before it. If `mdat` goes past 4 GiB the header becomes a large-size header, and chunk offsets - switch to `co64` when needed. -- Brands: `Brand::Mp4` writes `isom` with `isom iso2 avc1 mp41 mp42`. `Brand::Mov` writes `qt `, uses - QuickTime `hdlr`/Pascal names, and sets `wide` as the placeholder box. -- Tables written: `stts`, `ctts` (v1 when an offset is negative), `stss` (omitted when every sample is - sync), `stsc`, `stsz` (constant or table), `stco`/`co64`. Consecutive samples of one track share a chunk. -- Sample entries come from `SampleEntry` (`avc`, `hevc`, `prores`, `jpeg`, `aac`, `pcm`, or any demuxed - entry). Remuxing copies codec config, `colr`, `pasp`, `clap`, `fiel`, `gama` and `btrt`. - PCM is written as `sowt`/`twos` (16-bit) or `raw ` (8-bit) or sound description v2 `lpcm` in MOV, and as - `ipcm`/`fpcm` + `pcmC` in MP4. A PCM `write_sample` call can hold any whole number of frames, and the - track timescale must equal the sample rate. -- Edit lists: set `TrackConfig::edits` explicitly, or use `media_start` to get a single edit. -- Timecode: `add_timecode_track(video, TimecodeConfig, start_frame)` adds a `tmcd` track (with `gmhd`) - and a `tref/tmcd` link from the video track. -- `WriterOptions::metadata` writes `©xxx` text atoms to `udta`. -- `FragmentedWriter` writes fMP4. The init segment is `ftyp` + `moov` with `mvex/trex`. Each - `flush_fragment()` then writes `moof` (`mfhd`, `traf` with default-base-is-moof `tfhd`, `tfdt` v1, - `trun` with per-sample duration, size, flags and composition offset) followed by `mdat`. - -## Tests -- Unit tests cover the box reader and hand-built `stbl` edge cases (stz2, co64, ctts v1, empty edits, - 64-bit and size-0 boxes, uuid/wide/skip), codec config round-trips, and allocation guards. -- Property tests (`tests/roundtrip.rs`) write random multi-track files, both progressive (MP4/MOV, - faststart, PCM, edits) and fragmented, then read them back and compare sample tables and bytes. -- ffprobe oracle tests (`tests/oracle_demux.rs`) run on ffmpeg-generated fixtures in - `target/fixtures/isobmff/`: H.264 with B-frames (MP4 and MOV), HEVC, ProRes 422/4444, MJPEG, DNxHR, PCM - s16le/s16be/s24le/f32le, AAC, ALAC, FLAC, Opus, AC-3, VP9, AV1, fragmented (with and without - default-base-is-moof), faststart, an edit-list cut, `tmcd` at 29.97 DF and 23.976, and colr/pasp. They - compare packet offsets, sizes, pts/dts, durations and key flags, plus stream parameters. Tests skip - with a message when ffmpeg or an encoder is missing. -- Muxer oracle tests (`tests/oracle_mux.rs`) remux fixtures with our writers. They check that - `ffmpeg -v error -f null` reports nothing, that ffprobe packets and stream parameters match the source, - and that timecode, the AAC priming edit, ipcm and fMP4 output are correct. -- Mutation fuzzing (`tests/fuzz.rs`) uses a deterministic seed to mutate and truncate synthetic and - ffmpeg files, and checks that nothing panics. - -## Limitations / not yet supported -- `presentation time = pts + edit_offset` only applies the leading empty edits and the first media - edit. Multi-segment edit lists, dwell edits and non-unity rates are exposed raw. ffmpeg handles an edit - that starts mid-frame differently: it snaps to the frame. -- Other data references (external `alis`/`url ` files), encrypted entries (`encv`/`enca`, `sinf`, - `senc`), `cmov`, and QuickTime reference movies are not supported. -- `sidx`/`mfra` are ignored, because the demuxer scans every top-level box. `stsd` entries with a - QuickTime colour table are not decoded. -- Subtitle and text tracks (`tx3g`, `c608`, `wvtt`) and `mp4v` show up as `Unknown`. -- Muxer: one `mdat`, no chunk-size or duration limits (the caller controls interleaving), no `sdtp`, - `sgpd`/`sbgp` or `cslg` output, ISO-style `meta/ilst` metadata is not written, and the fragmented - writer has no PCM, `sidx` or `mfra`. +A re-export of `craft_media::isobmff`. Documentation: https://github.com/storytold/craft-libs/blob/main/crates/media/README.md diff --git a/crates/isobmff/src/bytes.rs b/crates/isobmff/src/bytes.rs deleted file mode 100644 index 97f92690b..000000000 --- a/crates/isobmff/src/bytes.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! Low-level helpers: four-character codes, a bounds-checked big-endian cursor, box iteration -//! over in-memory payloads, and a box builder for the muxers. - -use crate::error::{Error, Result}; -use std::fmt; - -/// A four-character code (box type, sample entry format, brand, handler type…). -#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] -pub struct FourCc(pub [u8; 4]); - -impl FourCc { - pub const fn new(b: &[u8; 4]) -> Self { - FourCc(*b) - } - pub fn as_u32(self) -> u32 { - u32::from_be_bytes(self.0) - } - /// Lossy string form; `©` (0xA9) is mapped to U+00A9, other non-printables to `?`. - pub fn to_string_lossy(self) -> String { - self.0 - .iter() - .map(|&b| match b { - 0xA9 => '©', - 0x20..=0x7E => b as char, - _ => '?', - }) - .collect() - } -} - -impl fmt::Display for FourCc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.to_string_lossy()) - } -} -impl fmt::Debug for FourCc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "FourCc({:?})", self.to_string_lossy()) - } -} -impl From<&[u8; 4]> for FourCc { - fn from(b: &[u8; 4]) -> Self { - FourCc(*b) - } -} -impl PartialEq<[u8; 4]> for FourCc { - fn eq(&self, other: &[u8; 4]) -> bool { - &self.0 == other - } -} -impl PartialEq<&[u8; 4]> for FourCc { - fn eq(&self, other: &&[u8; 4]) -> bool { - &self.0 == *other - } -} -impl PartialEq<&&[u8; 4]> for FourCc { - fn eq(&self, other: &&&[u8; 4]) -> bool { - &self.0 == **other - } -} - -/// Bounds-checked big-endian cursor over a byte slice. -#[derive(Clone)] -pub(crate) struct Cur<'a> { - pub data: &'a [u8], - pub pos: usize, - what: &'static str, -} - -impl<'a> Cur<'a> { - pub fn new(data: &'a [u8], what: &'static str) -> Self { - Cur { data, pos: 0, what } - } - pub fn remaining(&self) -> usize { - self.data.len().saturating_sub(self.pos) - } - pub fn rest(&self) -> &'a [u8] { - self.data.get(self.pos..).unwrap_or(&[]) - } - pub fn bytes(&mut self, n: usize) -> Result<&'a [u8]> { - let end = self.pos.checked_add(n).ok_or(Error::Truncated(self.what))?; - let s = self.data.get(self.pos..end).ok_or(Error::Truncated(self.what))?; - self.pos = end; - Ok(s) - } - pub fn skip(&mut self, n: usize) -> Result<()> { - self.bytes(n).map(|_| ()) - } - pub fn array(&mut self) -> Result<[u8; N]> { - let s = self.bytes(N)?; - let mut a = [0u8; N]; - a.copy_from_slice(s); - Ok(a) - } - pub fn u8(&mut self) -> Result { - Ok(self.array::<1>()?[0]) - } - pub fn u16(&mut self) -> Result { - Ok(u16::from_be_bytes(self.array()?)) - } - pub fn i16(&mut self) -> Result { - Ok(i16::from_be_bytes(self.array()?)) - } - pub fn u24(&mut self) -> Result { - let a = self.array::<3>()?; - Ok(((a[0] as u32) << 16) | ((a[1] as u32) << 8) | a[2] as u32) - } - pub fn u32(&mut self) -> Result { - Ok(u32::from_be_bytes(self.array()?)) - } - pub fn i32(&mut self) -> Result { - Ok(i32::from_be_bytes(self.array()?)) - } - pub fn u64(&mut self) -> Result { - Ok(u64::from_be_bytes(self.array()?)) - } - pub fn i64(&mut self) -> Result { - Ok(i64::from_be_bytes(self.array()?)) - } - pub fn f64(&mut self) -> Result { - Ok(f64::from_be_bytes(self.array()?)) - } - pub fn fourcc(&mut self) -> Result { - Ok(FourCc(self.array()?)) - } - /// Full-box header: (version, flags). - pub fn full_header(&mut self) -> Result<(u8, u32)> { - let v = self.u32()?; - Ok(((v >> 24) as u8, v & 0x00FF_FFFF)) - } - /// Read a count and verify that `count * entry_size` bytes remain (allocation guard). - pub fn count(&mut self, entry_size: usize) -> Result { - let n = self.u32()? as usize; - self.check_count(n, entry_size)?; - Ok(n) - } - pub fn check_count(&self, n: usize, entry_size: usize) -> Result<()> { - match n.checked_mul(entry_size) { - Some(b) if b <= self.remaining() => Ok(()), - _ => Err(Error::Truncated(self.what)), - } - } -} - -/// Header of one box as found in a byte stream. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct BoxHeader { - pub kind: FourCc, - /// Header length including largesize and uuid extended type. - pub header_len: u64, - /// Total box size including header; `None` means "extends to end of the enclosing container". - pub size: Option, - pub uuid: Option<[u8; 16]>, -} - -/// Parse a box header from `buf` (needs up to 32 bytes; fewer if available). -pub(crate) fn parse_box_header(buf: &[u8]) -> Result { - let mut c = Cur::new(buf, "box header"); - let size32 = c.u32()?; - let kind = c.fourcc()?; - let mut header_len = 8u64; - let size = match size32 { - 0 => None, - 1 => { - header_len += 8; - let s = c.u64()?; - Some(s) - } - s => Some(s as u64), - }; - let uuid = if kind == b"uuid" { - header_len += 16; - Some(c.array::<16>()?) - } else { - None - }; - if let Some(s) = size - && s < header_len - { - return Err(Error::Invalid(format!("box '{kind}' size {s} smaller than its header"))); - } - Ok(BoxHeader { kind, header_len, size, uuid }) -} - -/// One child box inside an in-memory payload. -#[derive(Clone, Copy, Debug)] -pub(crate) struct RawBox<'a> { - pub kind: FourCc, - pub payload: &'a [u8], - #[allow(dead_code)] - pub uuid: Option<[u8; 16]>, -} - -/// Iterator over the child boxes of a payload. Yields an error for a malformed box and then stops. -pub(crate) struct Boxes<'a> { - data: &'a [u8], - pos: usize, - done: bool, -} - -pub(crate) fn boxes(data: &[u8]) -> Boxes<'_> { - Boxes { data, pos: 0, done: false } -} - -impl<'a> Iterator for Boxes<'a> { - type Item = Result>; - fn next(&mut self) -> Option { - if self.done { - return None; - } - let rest = &self.data[self.pos..]; - if rest.is_empty() { - self.done = true; - return None; - } - // QuickTime allows a 32-bit zero terminator at the end of some containers. - if rest.len() < 8 { - self.done = true; - if rest.iter().all(|&b| b == 0) { - return None; - } - return Some(Err(Error::Truncated("box header"))); - } - let h = match parse_box_header(rest) { - Ok(h) => h, - Err(e) => { - self.done = true; - return Some(Err(e)); - } - }; - let size = h.size.unwrap_or(rest.len() as u64); - if size > rest.len() as u64 { - self.done = true; - return Some(Err(Error::Invalid(format!("box '{}' size {} exceeds its container ({} bytes left)", h.kind, size, rest.len())))); - } - let size = size as usize; - let payload = &rest[h.header_len as usize..size]; - self.pos += size; - Some(Ok(RawBox { kind: h.kind, payload, uuid: h.uuid })) - } -} - -/// Find the first child of the given type (malformed trailing children are ignored). -pub(crate) fn find<'a>(data: &'a [u8], kind: &[u8; 4]) -> Option<&'a [u8]> { - boxes(data).map_while(|b| b.ok()).find(|b| b.kind == kind).map(|b| b.payload) -} - -/// Growable buffer for building boxes. -#[derive(Default, Clone, Debug)] -pub(crate) struct BoxBuf { - pub buf: Vec, -} - -impl BoxBuf { - pub fn new() -> Self { - Self::default() - } - /// Begin a box; returns a marker to pass to [`end`](Self::end). - pub fn start(&mut self, kind: &[u8; 4]) -> usize { - let at = self.buf.len(); - self.buf.extend_from_slice(&[0, 0, 0, 0]); - self.buf.extend_from_slice(kind); - at - } - pub fn start_full(&mut self, kind: &[u8; 4], version: u8, flags: u32) -> usize { - let at = self.start(kind); - self.u32(((version as u32) << 24) | (flags & 0xFF_FFFF)); - at - } - pub fn end(&mut self, at: usize) { - let size = (self.buf.len() - at) as u32; - self.buf[at..at + 4].copy_from_slice(&size.to_be_bytes()); - } - /// Append a complete box with the given payload. - pub fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) { - let at = self.start(kind); - self.bytes(payload); - self.end(at); - } - pub fn u8(&mut self, v: u8) { - self.buf.push(v); - } - pub fn u16(&mut self, v: u16) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i16(&mut self, v: i16) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn u32(&mut self, v: u32) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i32(&mut self, v: i32) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn u64(&mut self, v: u64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i64(&mut self, v: i64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn f64(&mut self, v: f64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn bytes(&mut self, b: &[u8]) { - self.buf.extend_from_slice(b); - } - pub fn zeros(&mut self, n: usize) { - self.buf.resize(self.buf.len() + n, 0); - } - pub fn len(&self) -> usize { - self.buf.len() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn header_forms() { - let h = parse_box_header(&[0, 0, 0, 16, b'f', b'r', b'e', b'e']).unwrap(); - assert_eq!((h.kind, h.header_len, h.size), (FourCc(*b"free"), 8, Some(16))); - let h = parse_box_header(&[0, 0, 0, 0, b'm', b'd', b'a', b't']).unwrap(); - assert_eq!(h.size, None); - let mut v = vec![0, 0, 0, 1, b'm', b'd', b'a', b't']; - v.extend_from_slice(&100u64.to_be_bytes()); - let h = parse_box_header(&v).unwrap(); - assert_eq!((h.header_len, h.size), (16, Some(100))); - let mut v = vec![0, 0, 0, 30, b'u', b'u', b'i', b'd']; - v.extend_from_slice(&[7u8; 16]); - let h = parse_box_header(&v).unwrap(); - assert_eq!((h.header_len, h.uuid), (24, Some([7u8; 16]))); - assert!(parse_box_header(&[0, 0, 0, 4, b'f', b'r', b'e', b'e']).is_err()); - assert!(parse_box_header(&[0, 0, 0]).is_err()); - } - - #[test] - fn iterate_children() { - let mut b = BoxBuf::new(); - b.leaf(b"aaaa", &[1, 2]); - let m = b.start(b"bbbb"); - b.leaf(b"cccc", &[]); - b.end(m); - b.u32(0); // QT terminator - let kids: Vec<_> = boxes(&b.buf).collect::>>().unwrap(); - assert_eq!(kids.len(), 2); - assert_eq!(kids[0].payload, &[1, 2]); - assert_eq!(find(&b.buf, b"bbbb").map(|p| p.len()), Some(8)); - // Oversized child → error, no panic. - let bad = [0u8, 0, 0, 99, b'x', b'x', b'x', b'x', 0]; - assert!(boxes(&bad).next().unwrap().is_err()); - } - - #[test] - fn cursor_bounds() { - let mut c = Cur::new(&[0, 0, 0, 5, 1], "t"); - assert!(c.count(1).is_err()); - let mut c = Cur::new(&[0xFF, 0xFF, 0xFF, 0xFF], "t"); - assert!(c.count(usize::MAX).is_err()); - } -} diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs deleted file mode 100644 index 1fbcff9ce..000000000 --- a/crates/isobmff/src/codec.rs +++ /dev/null @@ -1,1724 +0,0 @@ -//! Sample entries (`stsd` children) and codec configuration records: parsing and serialization. - -use crate::bytes::{BoxBuf, Cur, FourCc, boxes}; -use crate::error::{Error, Result}; -use filmcraft_bitstream::BitReader; - -/// One sample description (an entry of `stsd`). -#[derive(Clone, Debug, PartialEq)] -pub struct SampleEntry { - /// Sample entry format (`avc1`, `mp4a`, `apch`, …). - pub format: FourCc, - pub data_reference_index: u16, - pub codec: CodecConfig, - /// Present for visual sample entries. - pub video: Option, - /// Present for audio sample entries. - pub audio: Option, - /// `btrt` box. - pub bitrate: Option, -} - -/// Fields common to all visual sample entries plus the optional descriptive boxes. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct VideoParams { - pub width: u16, - pub height: u16, - /// Horizontal/vertical resolution in pixels per inch (16.16 fixed point; 72 dpi = 0x0048_0000). - pub horiz_resolution: u32, - pub vert_resolution: u32, - pub frame_count: u16, - pub compressor_name: String, - pub depth: u16, - /// QuickTime `vendor` / version fields (zero in ISO files). - pub vendor: FourCc, - pub temporal_quality: u32, - pub spatial_quality: u32, - pub color: Option, - /// `pasp`: (hSpacing, vSpacing). - pub pixel_aspect: Option<(u32, u32)>, - pub clean_aperture: Option, - pub field_info: Option, - /// QuickTime `gama` (16.16 fixed point). - pub gamma: Option, - /// `mdcv`: mastering display colour volume (SMPTE ST 2086). - pub mastering_display: Option, - /// `clli`: content light level (MaxCLL, MaxFALL in cd/m²; 0 = unknown). - pub content_light: Option<(u16, u16)>, -} - -/// SMPTE ST 2086 mastering display metadata, in the units of the `mdcv` box and the H.264/HEVC -/// SEI: chromaticities in 0.00002 steps (order G, B, R as the SEI), luminance in 0.0001 cd/m². -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MasteringDisplay { - pub primaries: [(u16, u16); 3], - pub white_point: (u16, u16), - pub max_luminance: u32, - pub min_luminance: u32, -} - -impl MasteringDisplay { - /// BT.2020 primaries, D65, with the given peak and black in cd/m². - pub fn bt2020(max_nits: f64, min_nits: f64) -> Self { - let q = |v: f64| (v / 0.00002).round() as u16; - MasteringDisplay { - primaries: [(q(0.170), q(0.797)), (q(0.131), q(0.046)), (q(0.708), q(0.292))], - white_point: (q(0.3127), q(0.3290)), - max_luminance: (max_nits * 10_000.0).round() as u32, - min_luminance: (min_nits * 10_000.0).round() as u32, - } - } - pub fn max_nits(&self) -> f64 { - self.max_luminance as f64 / 10_000.0 - } - /// The 24-byte payload shared by `mdcv` and the SEI message. - pub fn to_bytes(&self) -> Vec { - let mut v = Vec::with_capacity(24); - for (x, y) in self.primaries { - v.extend_from_slice(&x.to_be_bytes()); - v.extend_from_slice(&y.to_be_bytes()); - } - v.extend_from_slice(&self.white_point.0.to_be_bytes()); - v.extend_from_slice(&self.white_point.1.to_be_bytes()); - v.extend_from_slice(&self.max_luminance.to_be_bytes()); - v.extend_from_slice(&self.min_luminance.to_be_bytes()); - v - } - pub fn parse(p: &[u8]) -> Option { - if p.len() < 24 { - return None; - } - let u16_at = |i: usize| u16::from_be_bytes([p[i], p[i + 1]]); - let u32_at = |i: usize| u32::from_be_bytes([p[i], p[i + 1], p[i + 2], p[i + 3]]); - Some(MasteringDisplay { - primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))], - white_point: (u16_at(12), u16_at(14)), - max_luminance: u32_at(16), - min_luminance: u32_at(20), - }) - } -} - -/// `colr` box contents. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ColorInfo { - /// ISO `nclx` (with full-range flag). - Nclx { primaries: u16, transfer: u16, matrix: u16, full_range: bool }, - /// QuickTime `nclc`. - Nclc { primaries: u16, transfer: u16, matrix: u16 }, - /// Embedded ICC profile (`prof` restricted/unrestricted `rICC`). - Icc { kind: FourCc, profile: Vec }, -} - -/// `clap` clean aperture (all rationals num/den; offsets are signed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CleanAperture { - pub width: (u32, u32), - pub height: (u32, u32), - pub horiz_offset: (i32, u32), - pub vert_offset: (i32, u32), -} - -/// QuickTime `fiel`: field count (1 progressive, 2 interlaced) and ordering detail -/// (0, 1 = top first, 6 = bottom first, 9 / 14 per the QuickTime spec). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FieldInfo { - pub fields: u8, - pub detail: u8, -} - -/// `btrt` box. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct BitRate { - pub buffer_size: u32, - pub max_bitrate: u32, - pub avg_bitrate: u32, -} - -/// Fields of audio sample entries (ISO v0 plus QuickTime sound description v1/v2 extensions). -#[derive(Clone, Debug, PartialEq, Default)] -pub struct AudioParams { - /// QuickTime sound description version (0, 1, 2). ISO files use 0. - pub qt_version: u16, - pub channels: u32, - /// Bits per sample from the entry (16 for most compressed formats). - pub sample_size: u16, - pub sample_rate: f64, - pub compression_id: i16, - /// v1 fields. - pub samples_per_packet: u32, - pub bytes_per_packet: u32, - pub bytes_per_frame: u32, - pub bytes_per_sample: u32, - /// v2 fields. - pub const_bits_per_channel: u32, - pub lpcm_flags: u32, - pub const_bytes_per_packet: u32, - pub const_frames_per_packet: u32, - /// `enda` little-endian flag inside a QuickTime `wave` box. - pub little_endian: Option, -} - -/// Codec configuration decoded from the sample entry. -#[derive(Clone, Debug, PartialEq)] -pub enum CodecConfig { - Avc(AvcConfig), - Hevc(HevcConfig), - Av1(Av1Config), - Vp9(VpcConfig), - /// Apple ProRes; fourcc is `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`. - ProRes { - fourcc: FourCc, - }, - /// Motion JPEG (`jpeg`, `mjpa`, `mjpb`). - Jpeg { - fourcc: FourCc, - }, - /// Avid DNxHD/DNxHR (`AVdn`, `AVdh`). - Dnx { - fourcc: FourCc, - }, - /// Advanced Professional Video (`apv1` + `apvC`). - Apv(ApvConfig), - Aac(AacConfig), - /// MPEG-1/2 audio (esds object type 0x69/0x6B, or QuickTime `.mp3` / `.mp2`). Usually layer - /// III, but these name no layer: ffmpeg stores MP2 as `mp4a` + 0x6B. Each frame header has it. - Mp3, - Pcm(PcmConfig), - /// ALAC magic cookie (ALACSpecificConfig, 24 bytes, optionally followed by a channel layout). - Alac { - cookie: Vec, - }, - Opus(OpusConfig), - Flac(FlacConfig), - /// `dac3` payload. - Ac3 { - dac3: Vec, - }, - /// `dec3` payload. - Eac3 { - dec3: Vec, - }, - Timecode(TimecodeConfig), - /// Anything else; `raw` is the full sample entry payload (after the 8-byte box header). - Unknown { - fourcc: FourCc, - raw: Vec, - }, -} - -impl CodecConfig { - /// A short human-readable codec name. - pub fn name(&self) -> &'static str { - match self { - CodecConfig::Avc(_) => "h264", - CodecConfig::Hevc(_) => "hevc", - CodecConfig::Av1(_) => "av1", - CodecConfig::Vp9(_) => "vp9", - CodecConfig::ProRes { .. } => "prores", - CodecConfig::Jpeg { .. } => "mjpeg", - CodecConfig::Dnx { .. } => "dnxhd", - CodecConfig::Apv(_) => "apv", - CodecConfig::Aac(_) => "aac", - CodecConfig::Mp3 => "mp3", - CodecConfig::Pcm(_) => "pcm", - CodecConfig::Alac { .. } => "alac", - CodecConfig::Opus(_) => "opus", - CodecConfig::Flac(_) => "flac", - CodecConfig::Ac3 { .. } => "ac3", - CodecConfig::Eac3 { .. } => "eac3", - CodecConfig::Timecode(_) => "timecode", - CodecConfig::Unknown { .. } => "unknown", - } - } -} - -/// `avcC` — AVCDecoderConfigurationRecord (ISO/IEC 14496-15 §5.3.3). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct AvcConfig { - pub profile: u8, - pub compatibility: u8, - pub level: u8, - /// NAL length field size in bytes (1, 2 or 4). - pub length_size: u8, - pub sps: Vec>, - pub pps: Vec>, - /// Bytes after the PPS list (high-profile extension: chroma format, bit depths, SPS-ext). - pub ext: Vec, -} - -impl AvcConfig { - /// Build from parameter sets (NAL units without start codes); profile/level taken from the first SPS. - pub fn new(sps: Vec>, pps: Vec>, length_size: u8) -> Self { - let (profile, compatibility, level) = sps.first().filter(|s| s.len() >= 4).map(|s| (s[1], s[2], s[3])).unwrap_or((0, 0, 0)); - AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: Vec::new() } - } - - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "avcC"); - let _version = c.u8()?; - let profile = c.u8()?; - let compatibility = c.u8()?; - let level = c.u8()?; - let length_size = (c.u8()? & 3) + 1; - let nsps = (c.u8()? & 0x1F) as usize; - let mut sps = Vec::with_capacity(nsps); - for _ in 0..nsps { - let n = c.u16()? as usize; - sps.push(c.bytes(n)?.to_vec()); - } - let npps = c.u8()? as usize; - let mut pps = Vec::with_capacity(npps); - for _ in 0..npps { - let n = c.u16()? as usize; - pps.push(c.bytes(n)?.to_vec()); - } - Ok(AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: c.rest().to_vec() }) - } - - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(1); - b.u8(self.profile); - b.u8(self.compatibility); - b.u8(self.level); - b.u8(0xFC | (self.length_size.clamp(1, 4) - 1)); - b.u8(0xE0 | (self.sps.len().min(31) as u8)); - for s in self.sps.iter().take(31) { - b.u16(s.len() as u16); - b.bytes(s); - } - b.u8(self.pps.len().min(255) as u8); - for p in self.pps.iter().take(255) { - b.u16(p.len() as u16); - b.bytes(p); - } - b.bytes(&self.ext); - b.buf - } -} - -/// One NAL array of an `hvcC` record. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct HevcNalArray { - pub completeness: bool, - pub nal_type: u8, - pub nalus: Vec>, -} - -/// `hvcC` — HEVCDecoderConfigurationRecord (ISO/IEC 14496-15 §8.3.3). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct HevcConfig { - pub general_profile_space: u8, - pub general_tier_flag: bool, - pub general_profile_idc: u8, - pub general_profile_compatibility_flags: u32, - /// 48 bits of general constraint indicator flags. - pub general_constraint_indicator_flags: u64, - pub general_level_idc: u8, - pub min_spatial_segmentation_idc: u16, - pub parallelism_type: u8, - pub chroma_format_idc: u8, - pub bit_depth_luma: u8, - pub bit_depth_chroma: u8, - pub avg_frame_rate: u16, - pub constant_frame_rate: u8, - pub num_temporal_layers: u8, - pub temporal_id_nested: bool, - pub length_size: u8, - pub arrays: Vec, -} - -impl HevcConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "hvcC"); - let _version = c.u8()?; - let b = c.u8()?; - let compat = c.u32()?; - let hi = c.u16()? as u64; - let lo = c.u32()? as u64; - let level = c.u8()?; - let min_seg = c.u16()? & 0x0FFF; - let par = c.u8()? & 3; - let chroma = c.u8()? & 3; - let bdl = (c.u8()? & 7) + 8; - let bdc = (c.u8()? & 7) + 8; - let afr = c.u16()?; - let x = c.u8()?; - let narrays = c.u8()? as usize; - let mut arrays = Vec::new(); - for _ in 0..narrays { - let t = c.u8()?; - let n = c.u16()? as usize; - c.check_count(n, 2)?; - let mut nalus = Vec::with_capacity(n); - for _ in 0..n { - let len = c.u16()? as usize; - nalus.push(c.bytes(len)?.to_vec()); - } - arrays.push(HevcNalArray { completeness: t & 0x80 != 0, nal_type: t & 0x3F, nalus }); - } - Ok(HevcConfig { - general_profile_space: b >> 6, - general_tier_flag: b & 0x20 != 0, - general_profile_idc: b & 0x1F, - general_profile_compatibility_flags: compat, - general_constraint_indicator_flags: (hi << 32) | lo, - general_level_idc: level, - min_spatial_segmentation_idc: min_seg, - parallelism_type: par, - chroma_format_idc: chroma, - bit_depth_luma: bdl, - bit_depth_chroma: bdc, - avg_frame_rate: afr, - constant_frame_rate: x >> 6, - num_temporal_layers: (x >> 3) & 7, - temporal_id_nested: x & 4 != 0, - length_size: (x & 3) + 1, - arrays, - }) - } - - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(1); - b.u8((self.general_profile_space << 6) | ((self.general_tier_flag as u8) << 5) | (self.general_profile_idc & 0x1F)); - b.u32(self.general_profile_compatibility_flags); - b.u16((self.general_constraint_indicator_flags >> 32) as u16); - b.u32(self.general_constraint_indicator_flags as u32); - b.u8(self.general_level_idc); - b.u16(0xF000 | (self.min_spatial_segmentation_idc & 0x0FFF)); - b.u8(0xFC | (self.parallelism_type & 3)); - b.u8(0xFC | (self.chroma_format_idc & 3)); - b.u8(0xF8 | (self.bit_depth_luma.saturating_sub(8) & 7)); - b.u8(0xF8 | (self.bit_depth_chroma.saturating_sub(8) & 7)); - b.u16(self.avg_frame_rate); - b.u8((self.constant_frame_rate << 6) - | ((self.num_temporal_layers & 7) << 3) - | ((self.temporal_id_nested as u8) << 2) - | (self.length_size.clamp(1, 4) - 1)); - b.u8(self.arrays.len() as u8); - for a in &self.arrays { - b.u8(((a.completeness as u8) << 7) | (a.nal_type & 0x3F)); - b.u16(a.nalus.len() as u16); - for n in &a.nalus { - b.u16(n.len() as u16); - b.bytes(n); - } - } - b.buf - } - - fn nalus_of(&self, t: u8) -> Vec<&[u8]> { - self.arrays.iter().filter(|a| a.nal_type == t).flat_map(|a| a.nalus.iter().map(|n| n.as_slice())).collect() - } - pub fn vps(&self) -> Vec<&[u8]> { - self.nalus_of(32) - } - pub fn sps(&self) -> Vec<&[u8]> { - self.nalus_of(33) - } - pub fn pps(&self) -> Vec<&[u8]> { - self.nalus_of(34) - } -} - -/// `av1C` — AV1CodecConfigurationRecord. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct Av1Config { - pub seq_profile: u8, - pub seq_level_idx_0: u8, - pub seq_tier_0: bool, - pub high_bitdepth: bool, - pub twelve_bit: bool, - pub monochrome: bool, - pub chroma_subsampling_x: bool, - pub chroma_subsampling_y: bool, - pub chroma_sample_position: u8, - pub initial_presentation_delay_minus_one: Option, - pub config_obus: Vec, -} - -impl Av1Config { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "av1C"); - let m = c.u8()?; - if m & 0x80 == 0 { - return Err(Error::Invalid("av1C marker bit not set".into())); - } - let a = c.u8()?; - let b = c.u8()?; - let d = c.u8()?; - Ok(Av1Config { - seq_profile: a >> 5, - seq_level_idx_0: a & 0x1F, - seq_tier_0: b & 0x80 != 0, - high_bitdepth: b & 0x40 != 0, - twelve_bit: b & 0x20 != 0, - monochrome: b & 0x10 != 0, - chroma_subsampling_x: b & 0x08 != 0, - chroma_subsampling_y: b & 0x04 != 0, - chroma_sample_position: b & 3, - initial_presentation_delay_minus_one: if d & 0x10 != 0 { Some(d & 0x0F) } else { None }, - config_obus: c.rest().to_vec(), - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(0x81); - b.u8((self.seq_profile << 5) | (self.seq_level_idx_0 & 0x1F)); - b.u8(((self.seq_tier_0 as u8) << 7) - | ((self.high_bitdepth as u8) << 6) - | ((self.twelve_bit as u8) << 5) - | ((self.monochrome as u8) << 4) - | ((self.chroma_subsampling_x as u8) << 3) - | ((self.chroma_subsampling_y as u8) << 2) - | (self.chroma_sample_position & 3)); - b.u8(match self.initial_presentation_delay_minus_one { - Some(d) => 0x10 | (d & 0x0F), - None => 0, - }); - b.bytes(&self.config_obus); - b.buf - } -} - -/// `vpcC` — VP codec configuration (version 1). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct VpcConfig { - pub profile: u8, - pub level: u8, - pub bit_depth: u8, - pub chroma_subsampling: u8, - pub full_range: bool, - pub colour_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coefficients: u8, - pub codec_init: Vec, -} - -impl VpcConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "vpcC"); - let (version, _) = c.full_header()?; - if version == 0 { - // Version 0 draft layout: profile, level, bitDepth(4)|colorSpace(4), chroma(4)|transfer(3)|range(1) - let profile = c.u8()?; - let level = c.u8()?; - let a = c.u8()?; - let b = c.u8()?; - return Ok(VpcConfig { profile, level, bit_depth: a >> 4, chroma_subsampling: b >> 4, full_range: b & 1 != 0, ..Default::default() }); - } - let profile = c.u8()?; - let level = c.u8()?; - let a = c.u8()?; - let colour_primaries = c.u8()?; - let transfer_characteristics = c.u8()?; - let matrix_coefficients = c.u8()?; - let n = c.u16()? as usize; - let codec_init = c.bytes(n)?.to_vec(); - Ok(VpcConfig { - profile, - level, - bit_depth: a >> 4, - chroma_subsampling: (a >> 1) & 7, - full_range: a & 1 != 0, - colour_primaries, - transfer_characteristics, - matrix_coefficients, - codec_init, - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u32(1 << 24); - b.u8(self.profile); - b.u8(self.level); - b.u8((self.bit_depth << 4) | ((self.chroma_subsampling & 7) << 1) | self.full_range as u8); - b.u8(self.colour_primaries); - b.u8(self.transfer_characteristics); - b.u8(self.matrix_coefficients); - b.u16(self.codec_init.len() as u16); - b.bytes(&self.codec_init); - b.buf - } -} - -/// AAC (MPEG-4 audio) configuration from `esds`. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct AacConfig { - /// AudioSpecificConfig bytes (DecoderSpecificInfo). - pub asc: Vec, - /// Audio object type (2 = AAC-LC, 5 = SBR/HE-AAC, 29 = PS …) as signalled first in the ASC. - pub object_type: u8, - /// Sampling rate signalled by the ASC (core rate; 0 if the ASC is missing). - pub sample_rate: u32, - /// Channel configuration from the ASC (0 = defined elsewhere). - pub channel_config: u8, - /// DecoderConfigDescriptor fields. - pub max_bitrate: u32, - pub avg_bitrate: u32, - pub buffer_size: u32, -} - -const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; - -impl AacConfig { - /// Build from an AudioSpecificConfig, decoding its header fields. - pub fn from_asc(asc: Vec) -> Self { - let (object_type, sample_rate, channel_config) = parse_asc(&asc).unwrap_or((0, 0, 0)); - AacConfig { asc, object_type, sample_rate, channel_config, max_bitrate: 0, avg_bitrate: 0, buffer_size: 0 } - } - /// Channel count implied by the channel configuration (ISO/IEC 14496-3 Table 1.19 with Amd 4: - /// 7 and 12 are 7.1 front / back, 11 is 6.1, 13 is 22.2, 14 is 7.1 with front heights), 0 if - /// unspecified (a PCE) or reserved. - pub fn channels(&self) -> u32 { - match self.channel_config { - 1..=6 => self.channel_config as u32, - 11 => 7, - 7 | 12 | 14 => 8, - 13 => 24, - _ => 0, - } - } -} - -/// Decode (audio object type, sampling frequency, channel configuration) from an AudioSpecificConfig. -pub fn parse_asc(asc: &[u8]) -> Option<(u8, u32, u8)> { - let mut r = BitReader::new(asc); - let aot = |r: &mut BitReader| -> Option { - let t = r.read_bits(5).ok()? as u8; - if t == 31 { Some(32 + r.read_bits(6).ok()? as u8) } else { Some(t) } - }; - let rate = |r: &mut BitReader| -> Option { - let i = r.read_bits(4).ok()? as usize; - if i == 15 { r.read_bits(24).ok() } else { AAC_RATES.get(i).copied() } - }; - let t = aot(&mut r)?; - let sr = rate(&mut r)?; - let ch = r.read_bits(4).ok()? as u8; - Some((t, sr, ch)) -} - -/// Uncompressed PCM description. -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub struct PcmConfig { - pub bits: u16, - pub float: bool, - pub big_endian: bool, - /// Signed integer samples (8-bit `raw ` is unsigned). - pub signed: bool, - pub channels: u32, - pub sample_rate: f64, -} - -impl PcmConfig { - /// Bytes per interleaved audio frame (all channels). - pub fn bytes_per_frame(&self) -> u32 { - (self.bits as u32).div_ceil(8) * self.channels.max(1) - } -} - -/// `dOps` — Opus specific box. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct OpusConfig { - pub output_channels: u8, - pub pre_skip: u16, - pub input_sample_rate: u32, - pub output_gain: i16, - pub channel_mapping_family: u8, - /// StreamCount, CoupledCount and ChannelMapping bytes when family != 0. - pub channel_mapping: Vec, -} - -impl OpusConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "dOps"); - let _version = c.u8()?; - Ok(OpusConfig { - output_channels: c.u8()?, - pre_skip: c.u16()?, - input_sample_rate: c.u32()?, - output_gain: c.i16()?, - channel_mapping_family: c.u8()?, - channel_mapping: c.rest().to_vec(), - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(0); - b.u8(self.output_channels); - b.u16(self.pre_skip); - b.u32(self.input_sample_rate); - b.i16(self.output_gain); - b.u8(self.channel_mapping_family); - b.bytes(&self.channel_mapping); - b.buf - } -} - -/// `dfLa` — FLAC metadata blocks, with STREAMINFO decoded. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct FlacConfig { - /// Raw metadata blocks (each with its 4-byte header) as stored in `dfLa`. - pub metadata_blocks: Vec, - pub sample_rate: u32, - pub channels: u8, - pub bits_per_sample: u8, - pub total_samples: u64, -} - -impl FlacConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "dfLa"); - c.full_header()?; - let blocks = c.rest().to_vec(); - let mut cfg = FlacConfig { metadata_blocks: blocks.clone(), ..Default::default() }; - let mut b = Cur::new(&blocks, "dfLa"); - let hdr = b.u32()?; - if (hdr >> 24) & 0x7F == 0 { - let si = b.bytes(((hdr & 0xFF_FFFF) as usize).min(34))?; - if si.len() >= 18 { - let mut r = BitReader::new(&si[10..18]); - cfg.sample_rate = r.read_bits(20).unwrap_or(0); - cfg.channels = r.read_bits(3).unwrap_or(0) as u8 + 1; - cfg.bits_per_sample = r.read_bits(5).unwrap_or(0) as u8 + 1; - cfg.total_samples = r.read_bits_u64(36).unwrap_or(0); - } - } - Ok(cfg) - } -} - -/// QuickTime timecode sample description (`tmcd`) plus the start frame read from its first sample. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct TimecodeConfig { - /// Bit 0 drop frame, bit 1 24-hour max, bit 2 negative times OK, bit 3 counter. - pub flags: u32, - pub timescale: u32, - pub frame_duration: u32, - /// Nominal frames per second (`numberOfFrames`), e.g. 30 for 29.97. - pub frames_per_second: u8, - /// Frame number of the first timecode sample (filled in by the demuxer). - pub start_frame: Option, - /// Source reel name (`name` box) if present. - pub name: Option, -} - -impl TimecodeConfig { - pub fn drop_frame(&self) -> bool { - self.flags & 1 != 0 - } - /// Format a frame number as `HH:MM:SS:FF` (`;` before the frames when drop-frame). - pub fn format_frame(&self, frame: u32) -> String { - let fps = self.frames_per_second.max(1) as u64; - let mut f = frame as u64; - if self.drop_frame() && fps >= 30 { - let drop = (fps / 30) * 2; // 2 frames per minute at 30, 4 at 60 - let per_min = fps * 60 - drop; - let per_10 = per_min * 10 + drop; - let d = f / per_10; - let m = f % per_10; - f += drop * 9 * d + if m > drop { drop * ((m - drop) / per_min) } else { 0 }; - } - let ff = f % fps; - let s = (f / fps) % 60; - let mi = (f / fps / 60) % 60; - let h = (f / fps / 3600) % 24; - let sep = if self.drop_frame() { ';' } else { ':' }; - format!("{h:02}:{mi:02}:{s:02}{sep}{ff:02}") - } -} - -/// `apvC` — APVDecoderConfigurationBox (`FullBox('apvC', version = 0, flags = 0)` containing -/// `APVDecoderConfigurationRecord`). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct ApvConfig { - pub profile_idc: u8, - pub level_idc: u8, - pub band_idc: u8, - pub frame_width: u32, - pub frame_height: u32, - pub chroma_format_idc: u8, - pub bit_depth_minus8: u8, - pub capture_time_distance: u8, - pub capture_time_distance_ignored: bool, - pub color_description_present: bool, - pub color_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coefficients: u8, - pub full_range: bool, - /// Raw `APVDecoderConfigurationRecord` bytes (after the 4-byte FullBox header). - pub raw_record: Vec, -} - -impl ApvConfig { - /// Build from a raw `APVDecoderConfigurationRecord` (starting with `configurationVersion = 1`). - pub fn from_record(record: Vec) -> Result { - let mut c = Cur::new(&record, "apvC"); - let version = c.u8()?; - if version != 1 { - return Err(Error::Invalid(format!("unsupported APVDecoderConfigurationRecord version {version}"))); - } - let num_entries = c.u8()?; - let mut cfg = ApvConfig { raw_record: record.clone(), ..Default::default() }; - if num_entries >= 1 && c.remaining() >= 2 { - let _pbu_type = c.u8()?; - let num_fi = c.u8()?; - if num_fi >= 1 && c.remaining() >= 14 { - let flags = c.u8()?; - cfg.color_description_present = (flags & 0x02) != 0; - cfg.capture_time_distance_ignored = (flags & 0x01) != 0; - cfg.profile_idc = c.u8()?; - cfg.level_idc = c.u8()?; - cfg.band_idc = c.u8()?; - cfg.frame_width = c.u32()?; - cfg.frame_height = c.u32()?; - let cb = c.u8()?; - cfg.chroma_format_idc = cb >> 4; - cfg.bit_depth_minus8 = cb & 0x0F; - cfg.capture_time_distance = c.u8()?; - if cfg.color_description_present && c.remaining() >= 4 { - cfg.color_primaries = c.u8()?; - cfg.transfer_characteristics = c.u8()?; - cfg.matrix_coefficients = c.u8()?; - cfg.full_range = (c.u8()? & 0x80) != 0; - } - } - } - Ok(cfg) - } - - /// Parse the payload of an `apvC` box (with or without the 4-byte `FullBox` header). - pub fn parse(data: &[u8]) -> Result { - if data.len() >= 5 && data[..4] == [0, 0, 0, 0] && data[4] == 1 { - Self::from_record(data[4..].to_vec()) - } else if !data.is_empty() && data[0] == 1 { - Self::from_record(data.to_vec()) - } else { - let mut c = Cur::new(data, "apvC"); - let (v, _) = c.full_header()?; - if v != 0 { - return Err(Error::Invalid(format!("unsupported apvC FullBox version {v}"))); - } - Self::from_record(c.rest().to_vec()) - } - } - - /// Serialize the full `apvC` box payload (4-byte `FullBox` header + `APVDecoderConfigurationRecord`). - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u32(0); // FullBox version = 0, flags = 0 - if !self.raw_record.is_empty() { - b.bytes(&self.raw_record); - return b.buf; - } - b.u8(1); // configurationVersion = 1 - b.u8(1); // number_of_configuration_entry = 1 - b.u8(1); // pbu_type = 1 (primary frame) - b.u8(1); // number_of_frame_info = 1 - let flags = (if self.color_description_present { 0x02 } else { 0 }) | (if self.capture_time_distance_ignored { 0x01 } else { 0 }); - b.u8(flags); - b.u8(self.profile_idc); - b.u8(self.level_idc); - b.u8(self.band_idc); - b.u32(self.frame_width); - b.u32(self.frame_height); - b.u8((self.chroma_format_idc << 4) | (self.bit_depth_minus8 & 0x0F)); - b.u8(self.capture_time_distance); - if self.color_description_present { - b.u8(self.color_primaries); - b.u8(self.transfer_characteristics); - b.u8(self.matrix_coefficients); - b.u8(if self.full_range { 0x80 } else { 0 }); - } - b.buf - } -} - -// --------------------------------------------------------------------------------------------- -// Parsing - -fn is_video_fourcc(f: &[u8; 4]) -> bool { - matches!( - f, - b"avc1" - | b"avc3" - | b"hvc1" - | b"hev1" - | b"av01" - | b"vp09" - | b"vp08" - | b"apch" - | b"apcn" - | b"apcs" - | b"apco" - | b"ap4h" - | b"ap4x" - | b"apv1" - | b"jpeg" - | b"mjpa" - | b"mjpb" - | b"AVdn" - | b"AVdh" - | b"mp4v" - | b"encv" - | b"raw " - | b"2vuy" - | b"v210" - ) -} - -fn is_audio_fourcc(f: &[u8; 4]) -> bool { - matches!( - f, - b"mp4a" - | b"lpcm" - | b"sowt" - | b"twos" - | b"in24" - | b"in32" - | b"fl32" - | b"fl64" - | b"ipcm" - | b"fpcm" - | b"alac" - | b"Opus" - | b"fLaC" - | b"ac-3" - | b"ec-3" - | b".mp3" - | b".mp2" - | b"enca" - | b"ulaw" - | b"alaw" - | b"NONE" - ) -} - -/// Parse one `stsd` entry. `handler` is the track handler type; `is_qt` selects QuickTime -/// interpretation of the sound description version field. -pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc, is_qt: bool) -> Result { - let mut c = Cur::new(payload, "sample entry"); - c.skip(6)?; - let data_reference_index = c.u16()?; - let f = &format.0; - let unknown = || CodecConfig::Unknown { fourcc: format, raw: payload.to_vec() }; - if f == b"tmcd" || (handler == b"tmcd" && !is_video_fourcc(f)) { - let mut tc = TimecodeConfig::default(); - c.skip(4)?; - tc.flags = c.u32()?; - tc.timescale = c.u32()?; - tc.frame_duration = c.u32()?; - tc.frames_per_second = c.u8()?; - c.skip(1)?; - for b in boxes(c.rest()).map_while(|b| b.ok()) { - if b.kind == b"name" && b.payload.len() >= 4 { - let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; - let s = b.payload.get(4..4 + n).unwrap_or(&b.payload[4..]); - tc.name = Some(String::from_utf8_lossy(s).into_owned()); - } - } - return Ok(SampleEntry { format, data_reference_index, codec: CodecConfig::Timecode(tc), video: None, audio: None, bitrate: None }); - } - let video_like = is_video_fourcc(f) || (handler == b"vide" && !is_audio_fourcc(f)); - let audio_like = is_audio_fourcc(f) || (handler == b"soun" && !video_like); - if video_like { - let mut v = VideoParams::default(); - let _version = c.u16()?; - let _revision = c.u16()?; - v.vendor = c.fourcc()?; - v.temporal_quality = c.u32()?; - v.spatial_quality = c.u32()?; - v.width = c.u16()?; - v.height = c.u16()?; - v.horiz_resolution = c.u32()?; - v.vert_resolution = c.u32()?; - c.skip(4)?; - v.frame_count = c.u16()?; - let name = c.bytes(32)?; - let n = (name[0] as usize).min(31); - v.compressor_name = String::from_utf8_lossy(&name[1..1 + n]).into_owned(); - v.depth = c.u16()?; - let _ctab = c.i16()?; - let mut codec = None; - let mut bitrate = None; - for b in boxes(c.rest()).map_while(|b| b.ok()) { - let p = b.payload; - match &b.kind.0 { - b"avcC" if matches!(f, b"avc1" | b"avc3") => codec = Some(CodecConfig::Avc(AvcConfig::parse(p)?)), - b"hvcC" if matches!(f, b"hvc1" | b"hev1") => codec = Some(CodecConfig::Hevc(HevcConfig::parse(p)?)), - b"av1C" if f == b"av01" => codec = Some(CodecConfig::Av1(Av1Config::parse(p)?)), - b"vpcC" if f == b"vp09" => codec = Some(CodecConfig::Vp9(VpcConfig::parse(p)?)), - b"apvC" if f == b"apv1" => codec = Some(CodecConfig::Apv(ApvConfig::parse(p)?)), - b"colr" => v.color = parse_colr(p).ok(), - b"mdcv" => v.mastering_display = MasteringDisplay::parse(p), - b"clli" if p.len() >= 4 => v.content_light = Some((u16::from_be_bytes([p[0], p[1]]), u16::from_be_bytes([p[2], p[3]]))), - b"pasp" => { - let mut c = Cur::new(p, "pasp"); - v.pixel_aspect = Some((c.u32()?, c.u32()?)); - } - b"clap" => { - let mut c = Cur::new(p, "clap"); - v.clean_aperture = Some(CleanAperture { - width: (c.u32()?, c.u32()?), - height: (c.u32()?, c.u32()?), - horiz_offset: (c.i32()?, c.u32()?), - vert_offset: (c.i32()?, c.u32()?), - }); - } - b"fiel" => { - let mut c = Cur::new(p, "fiel"); - v.field_info = Some(FieldInfo { fields: c.u8()?, detail: c.u8()? }); - } - b"gama" => v.gamma = Some(Cur::new(p, "gama").u32()?), - b"btrt" => bitrate = Some(parse_btrt(p)?), - _ => {} - } - } - let codec = match codec { - Some(c) => c, - None => match f { - b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => CodecConfig::ProRes { fourcc: format }, - b"jpeg" | b"mjpa" | b"mjpb" => CodecConfig::Jpeg { fourcc: format }, - b"AVdn" | b"AVdh" => CodecConfig::Dnx { fourcc: format }, - b"apv1" => CodecConfig::Apv(ApvConfig::default()), - _ => unknown(), - }, - }; - return Ok(SampleEntry { format, data_reference_index, codec, video: Some(v), audio: None, bitrate }); - } - if audio_like { - let mut a = AudioParams { qt_version: c.u16()?, ..Default::default() }; - let _revision = c.u16()?; - let _vendor = c.u32()?; - a.channels = c.u16()? as u32; - a.sample_size = c.u16()?; - a.compression_id = c.i16()?; - let _packet_size = c.u16()?; - a.sample_rate = c.u32()? as f64 / 65536.0; - if is_qt && a.qt_version == 1 { - a.samples_per_packet = c.u32()?; - a.bytes_per_packet = c.u32()?; - a.bytes_per_frame = c.u32()?; - a.bytes_per_sample = c.u32()?; - } else if is_qt && a.qt_version == 2 { - let _size_of_struct = c.u32()?; - a.sample_rate = c.f64()?; - a.channels = c.u32()?; - let _always = c.u32()?; - a.const_bits_per_channel = c.u32()?; - a.lpcm_flags = c.u32()?; - a.const_bytes_per_packet = c.u32()?; - a.const_frames_per_packet = c.u32()?; - } - // Flatten QuickTime `wave` children into the list of extension boxes. - let mut ext: Vec<(FourCc, &[u8])> = Vec::new(); - for b in boxes(c.rest()).map_while(|b| b.ok()) { - if b.kind == b"wave" { - for w in boxes(b.payload).map_while(|b| b.ok()) { - ext.push((w.kind, w.payload)); - } - } else { - ext.push((b.kind, b.payload)); - } - } - let get = |k: &[u8; 4]| ext.iter().find(|(kk, _)| kk == k).map(|(_, p)| *p); - if let Some(p) = get(b"enda") { - let v = if p.len() >= 2 { u16::from_be_bytes([p[0], p[1]]) } else { p.first().copied().unwrap_or(0) as u16 }; - a.little_endian = Some(v != 0); - } - let bitrate = get(b"btrt").and_then(|p| parse_btrt(p).ok()); - let codec = match f { - b"mp4a" => match get(b"esds") { - Some(p) => parse_esds(p)?, - None => unknown(), - }, - b".mp3" | b".mp2" => CodecConfig::Mp3, - b"alac" => match get(b"alac") { - Some(p) if p.len() >= 4 => CodecConfig::Alac { cookie: p[4..].to_vec() }, - _ => unknown(), - }, - b"Opus" => match get(b"dOps") { - Some(p) => CodecConfig::Opus(OpusConfig::parse(p)?), - None => unknown(), - }, - b"fLaC" => match get(b"dfLa") { - Some(p) => CodecConfig::Flac(FlacConfig::parse(p)?), - None => unknown(), - }, - b"ac-3" => CodecConfig::Ac3 { dac3: get(b"dac3").unwrap_or(&[]).to_vec() }, - b"ec-3" => CodecConfig::Eac3 { dec3: get(b"dec3").unwrap_or(&[]).to_vec() }, - b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)), - b"ipcm" | b"fpcm" => { - let mut pcm = - PcmConfig { bits: a.sample_size, float: f == b"fpcm", big_endian: true, signed: true, channels: a.channels, sample_rate: a.sample_rate }; - if let Some(p) = get(b"pcmC") { - let mut c = Cur::new(p, "pcmC"); - c.full_header()?; - let flags = c.u8()?; - pcm.big_endian = flags & 1 == 0; - pcm.bits = c.u8()? as u16; - } - CodecConfig::Pcm(pcm) - } - _ => unknown(), - }; - return Ok(SampleEntry { format, data_reference_index, codec, video: None, audio: Some(a), bitrate }); - } - Ok(SampleEntry { format, data_reference_index, codec: unknown(), video: None, audio: None, bitrate: None }) -} - -fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig { - let mut p = PcmConfig { channels: a.channels, sample_rate: a.sample_rate, signed: true, ..Default::default() }; - match f { - b"lpcm" => { - p.bits = a.const_bits_per_channel as u16; - p.float = a.lpcm_flags & 1 != 0; - p.big_endian = a.lpcm_flags & 2 != 0; - p.signed = a.lpcm_flags & 4 != 0 || p.float; - if p.bits == 0 { - p.bits = a.sample_size; - } - } - b"sowt" => { - p.bits = if a.sample_size == 0 { 16 } else { a.sample_size }; - } - b"twos" => { - p.bits = if a.sample_size == 0 { 16 } else { a.sample_size }; - p.big_endian = true; - } - b"raw " | b"NONE" => { - p.bits = if a.sample_size == 0 { 8 } else { a.sample_size }; - p.signed = p.bits != 8; - p.big_endian = true; - } - b"in24" | b"in32" | b"fl32" | b"fl64" => { - p.bits = match f { - b"in24" => 24, - b"in32" | b"fl32" => 32, - _ => 64, - }; - p.float = f[0] == b'f'; - p.big_endian = !a.little_endian.unwrap_or(false); - } - _ => {} - } - p -} - -fn parse_btrt(p: &[u8]) -> Result { - let mut c = Cur::new(p, "btrt"); - Ok(BitRate { buffer_size: c.u32()?, max_bitrate: c.u32()?, avg_bitrate: c.u32()? }) -} - -fn parse_colr(p: &[u8]) -> Result { - let mut c = Cur::new(p, "colr"); - let kind = c.fourcc()?; - Ok(match &kind.0 { - b"nclx" => ColorInfo::Nclx { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()?, full_range: c.u8().map(|v| v & 0x80 != 0).unwrap_or(false) }, - b"nclc" => ColorInfo::Nclc { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()? }, - _ => ColorInfo::Icc { kind, profile: c.rest().to_vec() }, - }) -} - -fn descriptor(c: &mut Cur) -> Result<(u8, usize)> { - let tag = c.u8()?; - let mut len = 0usize; - for _ in 0..4 { - let b = c.u8()?; - len = (len << 7) | (b & 0x7F) as usize; - if b & 0x80 == 0 { - break; - } - } - Ok((tag, len)) -} - -fn parse_esds(p: &[u8]) -> Result { - let mut c = Cur::new(p, "esds"); - c.full_header()?; - let (tag, len) = descriptor(&mut c)?; - if tag != 3 { - return Err(Error::Invalid(format!("esds: expected ES_Descriptor, got tag {tag}"))); - } - let es = c.bytes(len.min(c.remaining()))?; - let mut c = Cur::new(es, "ES_Descriptor"); - c.u16()?; - let fl = c.u8()?; - if fl & 0x80 != 0 { - c.u16()?; - } - if fl & 0x40 != 0 { - let n = c.u8()? as usize; - c.skip(n)?; - } - if fl & 0x20 != 0 { - c.u16()?; - } - while c.remaining() > 0 { - let (tag, len) = descriptor(&mut c)?; - let body = c.bytes(len.min(c.remaining()))?; - if tag != 4 { - continue; - } - let mut d = Cur::new(body, "DecoderConfigDescriptor"); - let oti = d.u8()?; - let _stream_type = d.u8()?; - let buffer_size = d.u24()?; - let max_bitrate = d.u32()?; - let avg_bitrate = d.u32()?; - let mut asc = Vec::new(); - while d.remaining() > 0 { - let (t, l) = descriptor(&mut d)?; - let b = d.bytes(l.min(d.remaining()))?; - if t == 5 { - asc = b.to_vec(); - break; - } - } - return Ok(match oti { - 0x69 | 0x6B => CodecConfig::Mp3, - _ => { - let mut a = AacConfig::from_asc(asc); - a.max_bitrate = max_bitrate; - a.avg_bitrate = avg_bitrate; - a.buffer_size = buffer_size; - CodecConfig::Aac(a) - } - }); - } - Err(Error::Invalid("esds without DecoderConfigDescriptor".into())) -} - -// --------------------------------------------------------------------------------------------- -// Serialization (muxer) - -fn put_descriptor(b: &mut BoxBuf, tag: u8, body: &[u8]) { - b.u8(tag); - let n = body.len() as u32; - // Always use the 4-byte length form for simplicity (as many encoders do). - b.u8(0x80 | ((n >> 21) & 0x7F) as u8); - b.u8(0x80 | ((n >> 14) & 0x7F) as u8); - b.u8(0x80 | ((n >> 7) & 0x7F) as u8); - b.u8((n & 0x7F) as u8); - b.bytes(body); -} - -pub(crate) fn esds_payload(oti: u8, asc: &[u8], max_bitrate: u32, avg_bitrate: u32, buffer_size: u32) -> Vec { - let mut dcd = BoxBuf::new(); - dcd.u8(oti); - dcd.u8(0x15); // streamType audio (5) << 2 | upstream 0 | reserved 1 - dcd.u8((buffer_size >> 16) as u8); - dcd.u16(buffer_size as u16); - dcd.u32(max_bitrate); - dcd.u32(avg_bitrate); - if !asc.is_empty() { - put_descriptor(&mut dcd, 5, asc); - } - let mut es = BoxBuf::new(); - es.u16(1); // ES_ID - es.u8(0); - put_descriptor(&mut es, 4, &dcd.buf); - put_descriptor(&mut es, 6, &[2]); // SLConfigDescriptor, predefined = 2 (MP4) - let mut b = BoxBuf::new(); - b.u32(0); - put_descriptor(&mut b, 3, &es.buf); - b.buf -} - -impl SampleEntry { - /// Convenience constructor for a video entry. - pub fn video(format: FourCc, codec: CodecConfig, width: u16, height: u16) -> Self { - let compressor_name = match &codec { - CodecConfig::ProRes { fourcc } => match &fourcc.0 { - b"apch" => "Apple ProRes 422 HQ", - b"apcn" => "Apple ProRes 422", - b"apcs" => "Apple ProRes 422 LT", - b"apco" => "Apple ProRes 422 Proxy", - b"ap4h" => "Apple ProRes 4444", - _ => "Apple ProRes 4444 XQ", - }, - CodecConfig::Jpeg { .. } => "Photo - JPEG", - _ => "", - }; - SampleEntry { - format, - data_reference_index: 1, - codec, - video: Some(VideoParams { - width, - height, - horiz_resolution: 0x0048_0000, - vert_resolution: 0x0048_0000, - frame_count: 1, - compressor_name: compressor_name.into(), - depth: 24, - ..Default::default() - }), - audio: None, - bitrate: None, - } - } - - /// Convenience constructor for an audio entry (ISO v0 layout; the muxer upgrades PCM in MOV to v2). - pub fn audio(format: FourCc, codec: CodecConfig, channels: u32, sample_rate: f64, sample_size: u16) -> Self { - SampleEntry { - format, - data_reference_index: 1, - codec, - video: None, - audio: Some(AudioParams { channels, sample_rate, sample_size, ..Default::default() }), - bitrate: None, - } - } - - /// H.264 entry (`avc1`). - pub fn avc(cfg: AvcConfig, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"avc1"), CodecConfig::Avc(cfg), width, height) - } - /// HEVC entry (`hvc1`, parameter sets in the config only). - pub fn hevc(cfg: HevcConfig, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"hvc1"), CodecConfig::Hevc(cfg), width, height) - } - /// AV1 entry (`av01`). - pub fn av1(cfg: Av1Config, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), width, height) - } - /// ProRes entry; `fourcc` one of `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`. - pub fn prores(fourcc: FourCc, width: u16, height: u16) -> Self { - let mut e = Self::video(fourcc, CodecConfig::ProRes { fourcc }, width, height); - if let Some(v) = e.video.as_mut() - && matches!(&fourcc.0, b"ap4h" | b"ap4x") - { - v.depth = 32; - } - e - } - /// Avid DNxHD / DNxHR entry (`AVdn`, or `AVdh` for DNxHR). - pub fn dnx(fourcc: FourCc, width: u16, height: u16) -> Self { - let mut e = Self::video(fourcc, CodecConfig::Dnx { fourcc }, width, height); - if let Some(v) = e.video.as_mut() { - v.depth = 24; - } - e - } - /// Advanced Professional Video entry (`apv1` + `apvC`). - pub fn apv(cfg: ApvConfig, width: u16, height: u16) -> Self { - let mut e = Self::video(FourCc(*b"apv1"), CodecConfig::Apv(cfg), width, height); - if let Some(v) = e.video.as_mut() { - v.compressor_name = "APV".into(); - } - e - } - /// Motion-JPEG entry (`jpeg`). - pub fn jpeg(width: u16, height: u16) -> Self { - let f = FourCc(*b"jpeg"); - Self::video(f, CodecConfig::Jpeg { fourcc: f }, width, height) - } - /// AAC entry (`mp4a` + `esds`) from an AudioSpecificConfig. - pub fn aac(asc: Vec, channels: u32, sample_rate: u32) -> Self { - Self::audio(FourCc(*b"mp4a"), CodecConfig::Aac(AacConfig::from_asc(asc)), channels, sample_rate as f64, 16) - } - /// FLAC entry (`fLaC` + `dfLa`) from the 34-byte STREAMINFO body: the only metadata block. - pub fn flac(streaminfo: &[u8; 34]) -> Self { - let mut blocks = vec![0x80, 0, 0, 34]; - blocks.extend_from_slice(streaminfo); - let mut full = vec![0; 4]; - full.extend_from_slice(&blocks); - let cfg = FlacConfig::parse(&full).unwrap_or(FlacConfig { metadata_blocks: blocks, ..Default::default() }); - let (channels, rate, bits) = (u32::from(cfg.channels), f64::from(cfg.sample_rate), u16::from(cfg.bits_per_sample)); - Self::audio(FourCc(*b"fLaC"), CodecConfig::Flac(cfg), channels, rate, bits) - } - /// PCM entry. The muxer picks the on-disk format (`sowt`/`twos`/`lpcm` for MOV, `ipcm`/`fpcm` for MP4). - pub fn pcm(cfg: PcmConfig) -> Self { - Self::audio(FourCc(*b"lpcm"), CodecConfig::Pcm(cfg), cfg.channels, cfg.sample_rate, cfg.bits) - } - - /// Serialize as a complete sample entry box. For PCM the format is chosen from the brand. - pub(crate) fn write(&self, b: &mut BoxBuf, qt: bool) -> Result<()> { - if let CodecConfig::Unknown { fourcc, raw } = &self.codec { - let m = b.start(&fourcc.0); - b.bytes(raw); - b.end(m); - return Ok(()); - } - if let CodecConfig::Timecode(tc) = &self.codec { - let m = b.start(b"tmcd"); - b.zeros(6); - b.u16(self.data_reference_index); - b.u32(0); - b.u32(tc.flags); - b.u32(tc.timescale); - b.u32(tc.frame_duration); - b.u8(tc.frames_per_second); - b.u8(0); - if let Some(name) = &tc.name { - let n = b.start(b"name"); - b.u16(name.len() as u16); - b.u16(0); - b.bytes(name.as_bytes()); - b.end(n); - } - b.end(m); - return Ok(()); - } - if let Some(v) = &self.video { - let m = b.start(&self.format.0); - b.zeros(6); - b.u16(self.data_reference_index); - b.u16(0); - b.u16(0); - b.u32(v.vendor.as_u32()); - b.u32(v.temporal_quality); - b.u32(v.spatial_quality); - b.u16(v.width); - b.u16(v.height); - b.u32(v.horiz_resolution); - b.u32(v.vert_resolution); - b.u32(0); - b.u16(v.frame_count); - let name = v.compressor_name.as_bytes(); - let n = name.len().min(31); - let mut nb = [0u8; 32]; - nb[0] = n as u8; - nb[1..1 + n].copy_from_slice(&name[..n]); - b.bytes(&nb); - b.u16(v.depth); - b.i16(-1); - match &self.codec { - CodecConfig::Avc(c) => b.leaf(b"avcC", &c.to_bytes()), - CodecConfig::Hevc(c) => b.leaf(b"hvcC", &c.to_bytes()), - CodecConfig::Av1(c) => b.leaf(b"av1C", &c.to_bytes()), - CodecConfig::Vp9(c) => b.leaf(b"vpcC", &c.to_bytes()), - CodecConfig::Apv(c) => b.leaf(b"apvC", &c.to_bytes()), - _ => {} - } - if let Some(g) = v.gamma { - b.leaf(b"gama", &g.to_be_bytes()); - } - if let Some(fi) = v.field_info { - b.leaf(b"fiel", &[fi.fields, fi.detail]); - } - if let Some(ci) = &v.color { - let m = b.start(b"colr"); - match ci { - ColorInfo::Nclx { primaries, transfer, matrix, full_range } => { - b.bytes(b"nclx"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - b.u8(if *full_range { 0x80 } else { 0 }); - } - ColorInfo::Nclc { primaries, transfer, matrix } => { - b.bytes(b"nclc"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - } - ColorInfo::Icc { kind, profile } => { - b.bytes(&kind.0); - b.bytes(profile); - } - } - b.end(m); - } - if let Some(md) = &v.mastering_display { - b.leaf(b"mdcv", &md.to_bytes()); - } - if let Some((cll, fall)) = v.content_light { - let mut d = cll.to_be_bytes().to_vec(); - d.extend_from_slice(&fall.to_be_bytes()); - b.leaf(b"clli", &d); - } - if let Some((h, vv)) = v.pixel_aspect { - let m = b.start(b"pasp"); - b.u32(h); - b.u32(vv); - b.end(m); - } - if let Some(c) = v.clean_aperture { - let m = b.start(b"clap"); - b.u32(c.width.0); - b.u32(c.width.1); - b.u32(c.height.0); - b.u32(c.height.1); - b.i32(c.horiz_offset.0); - b.u32(c.horiz_offset.1); - b.i32(c.vert_offset.0); - b.u32(c.vert_offset.1); - b.end(m); - } - if let Some(br) = self.bitrate { - write_btrt(b, br); - } - b.end(m); - return Ok(()); - } - let a = self.audio.clone().ok_or_else(|| Error::Mux("sample entry has neither video nor audio params".into()))?; - // Choose the on-disk format and sound description version. - let (format, version, pcm) = match &self.codec { - CodecConfig::Pcm(p) => { - if qt { - if p.bits == 16 && !p.float { - (if p.big_endian { *b"twos" } else { *b"sowt" }, 0u16, Some(*p)) - } else if p.bits == 8 && !p.float { - (*b"raw ", 0, Some(*p)) - } else { - (*b"lpcm", 2, Some(*p)) - } - } else { - (if p.float { *b"fpcm" } else { *b"ipcm" }, 0, Some(*p)) - } - } - _ => (self.format.0, if qt { a.qt_version.min(2) } else { 0 }, None), - }; - let m = b.start(&format); - b.zeros(6); - b.u16(self.data_reference_index); - if version == 2 { - let p = pcm.unwrap_or_default(); - b.u16(2); - b.u16(0); - b.u32(0); - b.u16(3); - b.u16(16); - b.i16(-2); - b.u16(0); - b.u32(65536); - b.u32(72); - b.f64(p.sample_rate); - b.u32(p.channels); - b.u32(0x7F00_0000); - b.u32(p.bits as u32); - let flags = (p.float as u32) | ((p.big_endian as u32) << 1) | (((p.signed && !p.float) as u32) << 2) | 8; - b.u32(flags); - b.u32(p.bytes_per_frame()); - b.u32(1); - } else { - b.u16(version); - b.u16(0); - b.u32(0); - b.u16(a.channels.min(u16::MAX as u32) as u16); - b.u16(match pcm { - Some(p) => p.bits, - None => { - if a.sample_size == 0 { - 16 - } else { - a.sample_size - } - } - }); - b.i16(if version == 1 { a.compression_id } else { 0 }); - b.u16(0); - let sr = if a.sample_rate < 65536.0 { (a.sample_rate * 65536.0).round() as u32 } else { 0 }; - b.u32(sr); - if version == 1 { - b.u32(a.samples_per_packet); - b.u32(a.bytes_per_packet); - b.u32(a.bytes_per_frame); - b.u32(a.bytes_per_sample); - } - } - match &self.codec { - CodecConfig::Aac(c) => { - b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size)); - } - CodecConfig::Mp3 if format == *b"mp4a" => { - b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); - } - CodecConfig::Alac { cookie } => { - let m = b.start_full(b"alac", 0, 0); - b.bytes(cookie); - b.end(m); - } - CodecConfig::Opus(o) => b.leaf(b"dOps", &o.to_bytes()), - CodecConfig::Flac(fl) => { - let m = b.start_full(b"dfLa", 0, 0); - b.bytes(&fl.metadata_blocks); - b.end(m); - } - CodecConfig::Ac3 { dac3 } => b.leaf(b"dac3", dac3), - CodecConfig::Eac3 { dec3 } => b.leaf(b"dec3", dec3), - CodecConfig::Pcm(p) if !qt => { - let m = b.start_full(b"pcmC", 0, 0); - b.u8(if p.big_endian { 0 } else { 1 }); - b.u8(p.bits as u8); - b.end(m); - } - CodecConfig::Pcm(p) if p.channels == 6 => { - // QuickTime channel layout: kAudioChannelLayoutTag_MPEG_5_1_A (L R C LFE Ls Rs) - let m = b.start_full(b"chan", 0, 0); - b.u32((121 << 16) | 6); - b.u32(0); - b.u32(0); - b.end(m); - } - _ => {} - } - if let Some(br) = self.bitrate { - write_btrt(b, br); - } - b.end(m); - Ok(()) - } -} - -fn write_btrt(b: &mut BoxBuf, br: BitRate) { - let m = b.start(b"btrt"); - b.u32(br.buffer_size); - b.u32(br.max_bitrate); - b.u32(br.avg_bitrate); - b.end(m); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(e: &SampleEntry, qt: bool, handler: &[u8; 4]) -> SampleEntry { - let mut b = BoxBuf::new(); - e.write(&mut b, qt).unwrap(); - let bx = crate::bytes::boxes(&b.buf).next().unwrap().unwrap(); - parse_sample_entry(bx.kind, bx.payload, FourCc(*handler), qt).unwrap() - } - - #[test] - fn avcc_roundtrip() { - let cfg = AvcConfig::new(vec![vec![0x67, 0x64, 0x00, 0x1F, 0xAC]], vec![vec![0x68, 0xEB]], 4); - assert_eq!(cfg.profile, 0x64); - assert_eq!(AvcConfig::parse(&cfg.to_bytes()).unwrap(), cfg); - let mut e = SampleEntry::avc(cfg, 1920, 1080); - e.video.as_mut().unwrap().color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }); - e.video.as_mut().unwrap().pixel_aspect = Some((4, 3)); - e.video.as_mut().unwrap().field_info = Some(FieldInfo { fields: 2, detail: 9 }); - e.video.as_mut().unwrap().clean_aperture = Some(CleanAperture { width: (1920, 1), height: (1080, 1), horiz_offset: (-2, 1), vert_offset: (0, 1) }); - e.video.as_mut().unwrap().mastering_display = Some(MasteringDisplay::bt2020(1000.0, 0.0001)); - e.video.as_mut().unwrap().content_light = Some((1000, 400)); - e.bitrate = Some(BitRate { buffer_size: 1, max_bitrate: 2, avg_bitrate: 3 }); - assert_eq!(roundtrip(&e, false, b"vide"), e); - assert_eq!(MasteringDisplay::bt2020(1000.0, 0.0001).max_nits(), 1000.0); - } - - #[test] - fn hvcc_roundtrip() { - let cfg = HevcConfig { - general_profile_idc: 1, - general_profile_compatibility_flags: 0x6000_0000, - general_constraint_indicator_flags: 0x9000_0000_0000, - general_level_idc: 93, - chroma_format_idc: 1, - bit_depth_luma: 8, - bit_depth_chroma: 8, - length_size: 4, - num_temporal_layers: 1, - temporal_id_nested: true, - arrays: vec![ - HevcNalArray { completeness: true, nal_type: 32, nalus: vec![vec![0x40, 1]] }, - HevcNalArray { completeness: true, nal_type: 33, nalus: vec![vec![0x42, 1, 2]] }, - HevcNalArray { completeness: true, nal_type: 34, nalus: vec![vec![0x44, 1]] }, - ], - ..Default::default() - }; - let back = HevcConfig::parse(&cfg.to_bytes()).unwrap(); - assert_eq!(back, cfg); - assert_eq!(back.sps(), vec![&[0x42u8, 1, 2][..]]); - } - - #[test] - fn av1_vp9_opus_roundtrip() { - let a = Av1Config { seq_profile: 1, seq_level_idx_0: 8, high_bitdepth: true, config_obus: vec![1, 2], ..Default::default() }; - assert_eq!(Av1Config::parse(&a.to_bytes()).unwrap(), a); - let v = VpcConfig { - profile: 2, - level: 31, - bit_depth: 10, - chroma_subsampling: 1, - full_range: true, - colour_primaries: 9, - transfer_characteristics: 16, - matrix_coefficients: 9, - codec_init: vec![], - }; - assert_eq!(VpcConfig::parse(&v.to_bytes()).unwrap(), v); - let o = OpusConfig { output_channels: 2, pre_skip: 312, input_sample_rate: 48000, output_gain: 0, channel_mapping_family: 0, channel_mapping: vec![] }; - assert_eq!(OpusConfig::parse(&o.to_bytes()).unwrap(), o); - } - - #[test] - fn aac_channel_configuration_is_an_index() { - // 48 kHz AAC LC: configuration → channels (Table 1.19 with Amd 4; #922) - let channels = |c: u16| AacConfig::from_asc(((2 << 11) | (3 << 7) | (c << 3)).to_be_bytes().to_vec()).channels(); - assert_eq!([0, 1, 6, 7, 8, 11, 12, 13, 14, 15].map(channels), [0, 1, 6, 8, 0, 7, 8, 24, 8, 0]); - } - - #[test] - fn aac_esds_roundtrip() { - let e = SampleEntry::aac(vec![0x12, 0x10], 2, 44100); - match &e.codec { - CodecConfig::Aac(a) => { - assert_eq!((a.object_type, a.sample_rate, a.channel_config), (2, 44100, 2)); - } - _ => panic!(), - } - assert_eq!(roundtrip(&e, false, b"soun"), e); - assert_eq!(roundtrip(&e, true, b"soun"), e); - } - - /// QuickTime's `.mp2` entry (what ffmpeg writes for MP2 in MOV) is MPEG audio, as `.mp3` is, - /// even under a handler that does not say "sound" (#800). - #[test] - fn quicktime_mpeg_audio_entries() { - for fourcc in [*b".mp3", *b".mp2"] { - let e = SampleEntry::audio(FourCc(fourcc), CodecConfig::Mp3, 2, 48000.0, 16); - for handler in [b"soun", b"\0\0\0\0"] { - let back = roundtrip(&e, true, handler); - assert_eq!(back.codec, CodecConfig::Mp3, "{fourcc:?} {handler:?}"); - assert_eq!(back.audio.map(|a| (a.channels, a.sample_rate)), Some((2, 48000.0)), "{fourcc:?} {handler:?}"); - } - } - } - - #[test] - fn pcm_entries() { - for (bits, float, be, qt) in [ - (16, false, false, true), - (16, false, true, true), - (24, false, false, true), - (32, true, false, true), - (24, false, true, false), - (32, true, false, false), - ] { - let p = PcmConfig { bits, float, big_endian: be, signed: true, channels: 2, sample_rate: 48000.0 }; - let e = SampleEntry::pcm(p); - let back = roundtrip(&e, qt, b"soun"); - assert_eq!(back.codec, CodecConfig::Pcm(p), "{bits} {float} {be} {qt}"); - } - } - - #[test] - fn timecode_and_prores() { - let tc = TimecodeConfig { flags: 1, timescale: 30000, frame_duration: 1001, frames_per_second: 30, start_frame: None, name: Some("reel".into()) }; - let e = SampleEntry { - format: FourCc(*b"tmcd"), - data_reference_index: 1, - codec: CodecConfig::Timecode(tc.clone()), - video: None, - audio: None, - bitrate: None, - }; - assert_eq!(roundtrip(&e, true, b"tmcd"), e); - assert_eq!(tc.format_frame(107892), "01:00:00;00"); - let nd = TimecodeConfig { flags: 0, frames_per_second: 24, ..Default::default() }; - assert_eq!(nd.format_frame(86400), "01:00:00:00"); - let p = SampleEntry::prores(FourCc(*b"apch"), 1920, 1080); - assert_eq!(roundtrip(&p, true, b"vide"), p); - } - - #[test] - fn asc_parse() { - assert_eq!(parse_asc(&[0x11, 0x90]), Some((2, 48000, 2))); - assert_eq!(parse_asc(&[]), None); - } -} diff --git a/crates/isobmff/src/demux.rs b/crates/isobmff/src/demux.rs deleted file mode 100644 index b9b685ac0..000000000 --- a/crates/isobmff/src/demux.rs +++ /dev/null @@ -1,1172 +0,0 @@ -//! Demuxer: parses box structure lazily (headers + `moov`/`moof` only) and builds per-track sample tables. - -use crate::bytes::{Cur, FourCc, boxes, find, parse_box_header}; -use crate::codec::{CodecConfig, SampleEntry, parse_sample_entry}; -use crate::error::{Error, Result, invalid}; -use crate::source::ByteSource; -use std::collections::HashMap; - -/// Hard cap on per-track sample count (after PCM chunk merging). -const MAX_SAMPLES: usize = 1 << 26; -/// Hard cap on the size of an in-memory `moov`/`moof` box. -const MAX_HEADER_BOX: u64 = 1 << 30; - -/// One sample (access unit) of a track, in the track's media timescale. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub struct Sample { - /// Absolute byte offset in the file. - pub offset: u64, - pub size: u32, - /// Decode timestamp (raw media time, edit lists not applied). - pub dts: i64, - /// Presentation timestamp = dts + composition offset (raw media time). - pub pts: i64, - pub duration: u32, - pub is_sync: bool, - /// 1-based index into [`Track::entries`]. - pub description_index: u32, -} - -/// One edit list entry (`elst`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Edit { - /// Duration of this edit in the movie timescale. - pub segment_duration: u64, - /// Start time within the media (track timescale); `-1` marks an empty edit. - pub media_time: i64, - /// Media rate as 16.16 fixed point (0x0001_0000 = normal speed, 0 = dwell). - pub media_rate: i32, -} - -/// Broad classification of a track by its handler. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrackKind { - Video, - Audio, - Timecode, - Subtitle, - Other, -} - -/// A sample-to-group mapping (`sbgp`) with the matching descriptions (`sgpd`) kept raw. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct SampleGroup { - pub grouping_type: FourCc, - /// (sample_count, group_description_index) runs. - pub runs: Vec<(u32, u32)>, - /// Raw description entries from `sgpd`. - pub descriptions: Vec>, -} - -/// A demuxed track. -#[derive(Clone, Debug, PartialEq)] -pub struct Track { - pub id: u32, - pub kind: TrackKind, - pub handler: FourCc, - pub handler_name: String, - /// Media timescale (`mdhd`). - pub timescale: u32, - /// Media duration in the media timescale (`mdhd`). - pub duration: u64, - /// Track duration in the movie timescale (`tkhd`). - pub tkhd_duration: u64, - /// ISO 639-2/T language code (e.g. `und`, `eng`). - pub language: String, - /// `tkhd` presentation size (16.16 fixed point converted to integer pixels, rounded down). - pub width: u32, - pub height: u32, - pub enabled: bool, - pub alternate_group: u16, - /// `tkhd` volume (8.8 fixed point). - pub volume: i16, - /// `tkhd` transformation matrix. - pub matrix: [i32; 9], - pub entries: Vec, - pub samples: Vec, - pub edits: Vec, - /// Offset to add to raw pts to get presentation time (track timescale), derived from the - /// leading empty edits and the first non-empty edit's `media_time`. - pub edit_offset: i64, - /// Track references (`tref`): (type, track ids). - pub references: Vec<(FourCc, Vec)>, - /// Raw `sdtp` bytes (one per sample) if present. - pub sdtp: Vec, - pub sample_groups: Vec, - /// True if uncompressed audio samples were merged into one [`Sample`] per chunk. - pub pcm_chunked: bool, - sync_index: Vec, - pts_order: Vec, -} - -impl Track { - /// The first sample entry's codec configuration. - pub fn codec(&self) -> Option<&CodecConfig> { - self.entries.first().map(|e| &e.codec) - } - pub fn video(&self) -> Option<&crate::codec::VideoParams> { - self.entries.first().and_then(|e| e.video.as_ref()) - } - /// Clockwise quarter turns (0–3) the `tkhd` matrix applies for display, or None when it is not - /// a pure 0/90/180/270° rotation (mirrored, scaled or sheared — left to the caller). - /// - /// ISO/IEC 14496-12 §8.3.2: a sample point (p, q) is displayed at p′ = a·p + c·q + x, - /// q′ = b·p + d·q + y for the matrix {a, b, u, c, d, v, x, y, w}, with y pointing down. So - /// {0, 1, −1, 0} sends the top row to the right-hand column (90° clockwise — iPhone portrait - /// video), {−1, 0, 0, −1} is 180° and {0, −1, 1, 0} is 270°. Only the signs matter: a·d − b·c - /// carries the scale, which the presentation size already reflects. - pub fn display_rotation(&self) -> Option { - let [a, b, _, c, d, _, _, _, _] = self.matrix; - match (a.signum(), b.signum(), c.signum(), d.signum()) { - (1, 0, 0, 1) => Some(0), - (0, 1, -1, 0) => Some(1), - (-1, 0, 0, -1) => Some(2), - (0, -1, 1, 0) => Some(3), - _ => None, - } - } - pub fn audio(&self) -> Option<&crate::codec::AudioParams> { - self.entries.first().and_then(|e| e.audio.as_ref()) - } - /// Presentation (edit-adjusted) pts of sample `i`. - pub fn presentation_pts(&self, i: usize) -> Option { - self.samples.get(i).map(|s| s.pts.saturating_add(self.edit_offset)) - } - /// Index of the sample being presented at raw media time `pts` (the last sample, in - /// presentation order, whose pts is ≤ `pts`; the first sample if `pts` precedes all). - pub fn sample_at_pts(&self, pts: i64) -> Option { - if self.pts_order.is_empty() { - return None; - } - let k = self.pts_order.partition_point(|&i| self.samples[i as usize].pts <= pts); - Some(self.pts_order[k.saturating_sub(1)] as usize) - } - /// Like [`sample_at_pts`](Self::sample_at_pts) but for edit-adjusted presentation time. - pub fn sample_at_presentation_time(&self, t: i64) -> Option { - self.sample_at_pts(t.saturating_sub(self.edit_offset)) - } - /// The closest sync sample at or before `index` (decode order); 0 if there is none. - pub fn sync_sample_before(&self, index: usize) -> usize { - let k = self.sync_index.partition_point(|&i| i as usize <= index); - if k == 0 { 0 } else { self.sync_index[k - 1] as usize } - } - /// Indices of all sync samples. - pub fn sync_samples(&self) -> &[u32] { - &self.sync_index - } - - fn finish_indexes(&mut self) { - self.sync_index = self.samples.iter().enumerate().filter(|(_, s)| s.is_sync).map(|(i, _)| i as u32).collect(); - let mut order: Vec = (0..self.samples.len() as u32).collect(); - order.sort_by_key(|&i| (self.samples[i as usize].pts, i)); - self.pts_order = order; - } -} - -/// Text metadata from `udta` (QuickTime `©xxx` atoms and iTunes-style `meta/ilst`, incl. `keys`). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct Metadata { - pub entries: Vec<(String, String)>, -} - -impl Metadata { - pub fn get(&self, key: &str) -> Option<&str> { - self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) - } - /// Title from `©nam` or the `com.apple.quicktime.title` key. - pub fn title(&self) -> Option<&str> { - self.get("©nam").or_else(|| self.get("com.apple.quicktime.title")).or_else(|| self.get("title")) - } -} - -/// A parsed MP4/MOV file (headers and sample tables; media data stays in the source). -#[derive(Clone, Debug, PartialEq)] -pub struct Mp4File { - pub major_brand: FourCc, - pub minor_version: u32, - pub compatible_brands: Vec, - /// QuickTime file (major brand `qt ` or no `ftyp`). - pub is_quicktime: bool, - /// Movie timescale (`mvhd`). - pub timescale: u32, - /// Movie duration in the movie timescale (`mvhd`; for fragmented files, the longest track). - pub duration: u64, - pub tracks: Vec, - pub metadata: Metadata, - /// File uses movie fragments (`mvex` present). - pub fragmented: bool, - /// Byte offset of the `moov` box. - pub moov_offset: u64, - /// Number of `moof` boxes found. - pub fragment_count: usize, -} - -impl Mp4File { - /// Read the bytes of sample `index` of track `track` (index into [`tracks`](Self::tracks)). - pub fn read_sample(&self, src: &S, track: usize, index: usize) -> Result> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - read_range(src, s.offset, s.size as u64) - } - pub fn sample_at_pts(&self, track: usize, pts: i64) -> Option { - self.tracks.get(track)?.sample_at_pts(pts) - } - pub fn sync_sample_before(&self, track: usize, index: usize) -> Option { - Some(self.tracks.get(track)?.sync_sample_before(index)) - } - /// First track of the given kind. - pub fn track_of_kind(&self, kind: TrackKind) -> Option { - self.tracks.iter().position(|t| t.kind == kind) - } -} - -fn read_range(src: &S, offset: u64, size: u64) -> Result> { - let end = offset.checked_add(size).ok_or(Error::Truncated("sample"))?; - if end > src.len() { - return Err(Error::Truncated("sample data")); - } - let mut v = vec![0u8; size as usize]; - src.read_at(offset, &mut v)?; - Ok(v) -} - -/// Parse an MP4/MOV file. Only box headers, `ftyp`, `moov` and `moof` boxes are read. -pub fn open(src: S) -> Result { - let len = src.len(); - let mut offset = 0u64; - let mut ftyp: Option> = None; - let mut moov: Option<(u64, Vec)> = None; - let mut moofs: Vec<(u64, Vec)> = Vec::new(); - while offset + 8 <= len { - let n = (len - offset).min(32) as usize; - let mut hb = [0u8; 32]; - src.read_at(offset, &mut hb[..n])?; - let h = parse_box_header(&hb[..n])?; - let size = h.size.unwrap_or(len - offset); - let end = offset.saturating_add(size).min(len); - let body_len = end.saturating_sub(offset + h.header_len); - let read_body = |max: u64| -> Result> { - if body_len > max { - return Err(Error::TooLarge("header box")); - } - read_range(&src, offset + h.header_len, body_len) - }; - match &h.kind.0 { - b"ftyp" if ftyp.is_none() => ftyp = Some(read_body(1 << 16)?), - b"moov" if moov.is_none() => moov = Some((offset, read_body(MAX_HEADER_BOX)?)), - b"moof" => moofs.push((offset, read_body(MAX_HEADER_BOX)?)), - _ => {} - } - if h.size.is_none() { - break; - } - offset = offset.saturating_add(size); - } - let (moov_offset, moov) = moov.ok_or(Error::NoMoov)?; - let mut file = Mp4File { - major_brand: FourCc::default(), - minor_version: 0, - compatible_brands: Vec::new(), - is_quicktime: true, - timescale: 0, - duration: 0, - tracks: Vec::new(), - metadata: Metadata::default(), - fragmented: false, - moov_offset, - fragment_count: moofs.len(), - }; - if let Some(f) = &ftyp { - let mut c = Cur::new(f, "ftyp"); - file.major_brand = c.fourcc()?; - file.minor_version = c.u32().unwrap_or(0); - while c.remaining() >= 4 { - file.compatible_brands.push(c.fourcc()?); - } - file.is_quicktime = file.major_brand == b"qt "; - } - let mut trex: HashMap = HashMap::new(); - let mut trak_payloads = Vec::new(); - for b in boxes(&moov) { - let b = b?; - match &b.kind.0 { - b"cmov" => return Err(Error::Unsupported("compressed movie header (cmov)".into())), - b"mvhd" => { - let mut c = Cur::new(b.payload, "mvhd"); - let (v, _) = c.full_header()?; - if v == 1 { - c.skip(16)?; - file.timescale = c.u32()?; - file.duration = c.u64()?; - } else { - c.skip(8)?; - file.timescale = c.u32()?; - file.duration = c.u32()? as u64; - } - } - b"trak" => trak_payloads.push(b.payload), - b"mvex" => { - file.fragmented = true; - for m in boxes(b.payload).map_while(|b| b.ok()) { - if m.kind == b"trex" { - let mut c = Cur::new(m.payload, "trex"); - c.full_header()?; - let id = c.u32()?; - trex.insert(id, Trex { description_index: c.u32()?, duration: c.u32()?, size: c.u32()?, flags: c.u32()? }); - } - } - } - b"udta" => parse_udta(b.payload, &mut file.metadata), - b"meta" => parse_meta(b.payload, &mut file.metadata), - _ => {} - } - } - for p in trak_payloads { - let t = parse_trak(p, &file, len)?; - file.tracks.push(t); - } - // Movie fragments. - let mut next_dts: Vec = file.tracks.iter().map(|t| t.samples.last().map(|s| s.dts + s.duration as i64).unwrap_or(0)).collect(); - for (moof_off, moof) in &moofs { - parse_moof(*moof_off, moof, &mut file.tracks, &trex, &mut next_dts, len)?; - } - for t in &mut file.tracks { - if file.fragmented && !moofs.is_empty() { - let d: u64 = t.samples.iter().map(|s| s.duration as u64).sum(); - t.duration = t.duration.max(d); - if t.timescale > 0 && file.timescale > 0 { - let md = (t.duration as u128 * file.timescale as u128 / t.timescale as u128) as u64; - file.duration = file.duration.max(md); - } - } - t.finish_indexes(); - // Timecode start frame from the first sample. - if let Some(CodecConfig::Timecode(tc)) = t.entries.first_mut().map(|e| &mut e.codec) - && let Some(s) = t.samples.first() - && s.size >= 4 - { - let mut b = [0u8; 4]; - if src.read_at(s.offset, &mut b).is_ok() { - tc.start_frame = Some(u32::from_be_bytes(b)); - } - } - } - Ok(file) -} - -#[derive(Clone, Copy, Debug, Default)] -struct Trex { - description_index: u32, - duration: u32, - size: u32, - flags: u32, -} - -fn parse_language(code: u16) -> String { - if code < 0x400 { - // QuickTime Macintosh language code. - return if code == 0 { "eng".into() } else { "und".into() }; - } - let c = |s: u16| (((code >> s) & 0x1F) as u8 + 0x60) as char; - let s: String = [c(10), c(5), c(0)].into_iter().collect(); - if s.chars().all(|ch| ch.is_ascii_lowercase()) { s } else { "und".into() } -} - -fn rescale(v: i128, from: u32, to: u32) -> i64 { - if from == 0 { - return 0; - } - (v * to as i128 / from as i128).clamp(i64::MIN as i128, i64::MAX as i128) as i64 -} - -fn parse_trak(p: &[u8], file: &Mp4File, file_len: u64) -> Result { - let mut t = Track { - id: 0, - kind: TrackKind::Other, - handler: FourCc::default(), - handler_name: String::new(), - timescale: 0, - duration: 0, - tkhd_duration: 0, - language: "und".into(), - width: 0, - height: 0, - enabled: true, - alternate_group: 0, - volume: 0, - matrix: [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000], - entries: Vec::new(), - samples: Vec::new(), - edits: Vec::new(), - edit_offset: 0, - references: Vec::new(), - sdtp: Vec::new(), - sample_groups: Vec::new(), - pcm_chunked: false, - sync_index: Vec::new(), - pts_order: Vec::new(), - }; - let mut mdia = None; - for b in boxes(p) { - let b = b?; - match &b.kind.0 { - b"tkhd" => { - let mut c = Cur::new(b.payload, "tkhd"); - let (v, flags) = c.full_header()?; - t.enabled = flags & 1 != 0; - if v == 1 { - c.skip(16)?; - t.id = c.u32()?; - c.skip(4)?; - t.tkhd_duration = c.u64()?; - } else { - c.skip(8)?; - t.id = c.u32()?; - c.skip(4)?; - t.tkhd_duration = c.u32()? as u64; - } - c.skip(8)?; - let _layer = c.i16()?; - t.alternate_group = c.u16()?; - t.volume = c.i16()?; - c.skip(2)?; - for m in t.matrix.iter_mut() { - *m = c.i32()?; - } - t.width = c.u32()? >> 16; - t.height = c.u32()? >> 16; - } - b"edts" => { - if let Some(e) = find(b.payload, b"elst") { - let mut c = Cur::new(e, "elst"); - let (v, _) = c.full_header()?; - let n = c.count(if v == 1 { 20 } else { 12 })?; - for _ in 0..n { - let (segment_duration, media_time) = if v == 1 { (c.u64()?, c.i64()?) } else { (c.u32()? as u64, c.i32()? as i64) }; - t.edits.push(Edit { segment_duration, media_time, media_rate: c.i32()? }); - } - } - } - b"tref" => { - for r in boxes(b.payload).map_while(|b| b.ok()) { - let ids = r.payload.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(); - t.references.push((r.kind, ids)); - } - } - b"mdia" => mdia = Some(b.payload), - _ => {} - } - } - let mdia = mdia.ok_or_else(|| Error::Invalid("trak without mdia".into()))?; - let mut minf = None; - for b in boxes(mdia) { - let b = b?; - match &b.kind.0 { - b"mdhd" => { - let mut c = Cur::new(b.payload, "mdhd"); - let (v, _) = c.full_header()?; - if v == 1 { - c.skip(16)?; - t.timescale = c.u32()?; - t.duration = c.u64()?; - } else { - c.skip(8)?; - t.timescale = c.u32()?; - t.duration = c.u32()? as u64; - } - t.language = parse_language(c.u16()?); - } - b"hdlr" => { - let mut c = Cur::new(b.payload, "hdlr"); - c.full_header()?; - let _pre = c.u32()?; - t.handler = c.fourcc()?; - c.skip(12)?; - let name = c.rest(); - // ISO: null-terminated UTF-8; QuickTime: Pascal string. - let s = if !name.is_empty() && name[0] as usize == name.len() - 1 && name.len() > 1 { - &name[1..] - } else { - name.split(|&b| b == 0).next().unwrap_or(&[]) - }; - t.handler_name = String::from_utf8_lossy(s).into_owned(); - } - b"minf" => minf = Some(b.payload), - _ => {} - } - } - t.kind = match &t.handler.0 { - b"vide" => TrackKind::Video, - b"soun" => TrackKind::Audio, - b"tmcd" => TrackKind::Timecode, - b"text" | b"sbtl" | b"subt" | b"clcp" => TrackKind::Subtitle, - _ => TrackKind::Other, - }; - // Edit offset in media timescale. - let mut empty = 0i128; - for e in &t.edits { - if e.media_time == -1 { - empty += e.segment_duration as i128; - } else { - t.edit_offset = rescale(empty, file.timescale, t.timescale).saturating_sub(e.media_time); - break; - } - } - if let Some(stbl) = minf.and_then(|m| find(m, b"stbl")) { - parse_stbl(stbl, &mut t, file.is_quicktime, file_len)?; - } - Ok(t) -} - -struct SttsCursor<'a> { - runs: &'a [(u32, u32)], - run: usize, - used: u32, -} - -impl SttsCursor<'_> { - /// Advance by `n` samples, returning the summed duration. - fn take(&mut self, mut n: u64) -> u64 { - let mut total = 0u64; - while n > 0 && self.run < self.runs.len() { - let (count, delta) = self.runs[self.run]; - let avail = (count - self.used) as u64; - let k = avail.min(n); - total = total.saturating_add(k.saturating_mul(delta as u64)); - n -= k; - self.used += k as u32; - if self.used >= count { - self.run += 1; - self.used = 0; - } - } - total - } - fn next_value(&mut self) -> u32 { - while self.run < self.runs.len() && self.runs[self.run].0 == 0 { - self.run += 1; - } - let Some(&(_, d)) = self.runs.get(self.run) else { return 0 }; - self.take(1); - d - } -} - -fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64) -> Result<()> { - let mut stts: Vec<(u32, u32)> = Vec::new(); - let mut ctts: Vec<(u32, u32)> = Vec::new(); - let mut stss: Option> = None; - let mut stsc: Vec<(u32, u32, u32)> = Vec::new(); - let mut const_size = 0u32; - let mut sample_count = 0usize; - let mut sizes: Vec = Vec::new(); - let mut chunks: Vec = Vec::new(); - let mut sbgp: Vec<(FourCc, Vec<(u32, u32)>)> = Vec::new(); - let mut sgpd: HashMap>> = HashMap::new(); - for b in boxes(stbl) { - let b = b?; - let mut c = Cur::new(b.payload, "stbl child"); - match &b.kind.0 { - b"stsd" => { - c.full_header()?; - let n = c.count(8)?; - for e in boxes(c.rest()).take(n) { - let e = e?; - t.entries.push(parse_sample_entry(e.kind, e.payload, t.handler, is_qt)?); - } - } - b"stts" => { - c.full_header()?; - let n = c.count(8)?; - stts = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - } - b"ctts" => { - c.full_header()?; - let n = c.count(8)?; - ctts = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - } - b"stss" => { - c.full_header()?; - let n = c.count(4)?; - let mut v: Vec = (0..n).map(|_| c.u32()).collect::>()?; - v.sort_unstable(); - stss = Some(v); - } - b"stsc" => { - c.full_header()?; - let n = c.count(12)?; - stsc = (0..n).map(|_| Ok((c.u32()?, c.u32()?, c.u32()?))).collect::>()?; - } - b"stsz" => { - c.full_header()?; - const_size = c.u32()?; - let n = c.u32()? as usize; - if const_size == 0 { - c.check_count(n, 4)?; - sizes = (0..n).map(|_| c.u32()).collect::>()?; - sample_count = n; - } else { - // Samples of constant size must fit in the file: clamp absurd counts. - sample_count = (n as u64).min(file_len / const_size as u64) as usize; - } - } - b"stz2" => { - c.full_header()?; - let field = c.u32()? & 0xFF; - let n = c.u32()? as usize; - let bits = match field { - 4 | 8 | 16 => field as usize, - _ => return invalid(format!("stz2 field size {field}")), - }; - if n.checked_mul(bits).is_none_or(|b| b.div_ceil(8) > c.remaining()) { - return Err(Error::Truncated("stz2")); - } - let d = c.rest(); - sizes = (0..n) - .map(|i| match bits { - 4 => ((d[i / 2] >> if i % 2 == 0 { 4 } else { 0 }) & 0xF) as u32, - 8 => d[i] as u32, - _ => u16::from_be_bytes([d[2 * i], d[2 * i + 1]]) as u32, - }) - .collect(); - sample_count = n; - } - b"stco" => { - c.full_header()?; - let n = c.count(4)?; - chunks = (0..n).map(|_| c.u32().map(|v| v as u64)).collect::>()?; - } - b"co64" => { - c.full_header()?; - let n = c.count(8)?; - chunks = (0..n).map(|_| c.u64()).collect::>()?; - } - b"sdtp" => { - c.full_header()?; - t.sdtp = c.rest().to_vec(); - } - b"sbgp" => { - let (v, _) = c.full_header()?; - let gt = c.fourcc()?; - if v == 1 { - c.u32()?; - } - let n = c.count(8)?; - let runs = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - sbgp.push((gt, runs)); - } - b"sgpd" => { - let (v, _) = c.full_header()?; - let gt = c.fourcc()?; - let default_len = if v >= 1 { c.u32()? } else { 0 }; - if v >= 2 { - c.u32()?; - } - let n = c.count(0)?; - let mut descs = Vec::new(); - for _ in 0..n { - let l = if v >= 1 && default_len == 0 { c.u32()? } else { default_len }; - if l == 0 { - // Version 0 without known length: keep the remainder as a single entry. - descs.push(c.rest().to_vec()); - break; - } - descs.push(c.bytes(l as usize)?.to_vec()); - } - sgpd.insert(gt, descs); - } - _ => {} - } - } - t.sample_groups = sbgp.into_iter().map(|(gt, runs)| SampleGroup { grouping_type: gt, runs, descriptions: sgpd.remove(>).unwrap_or_default() }).collect(); - if sample_count == 0 || chunks.is_empty() || stsc.is_empty() { - return Ok(()); - } - if stsc[0].0 == 0 || stsc.windows(2).any(|w| w[1].0 <= w[0].0) { - return invalid("stsc first_chunk values are not increasing from 1"); - } - let pcm = match t.entries.first().map(|e| &e.codec) { - Some(CodecConfig::Pcm(p)) if const_size != 0 => Some(*p), - _ => None, - }; - let mut stts_c = SttsCursor { runs: &stts, run: 0, used: 0 }; - let mut ctts_c = SttsCursor { runs: &ctts, run: 0, used: 0 }; - let mut samples = Vec::new(); - let mut sidx = 0usize; - let mut dts = 0i64; - let mut stsc_i = 0usize; - if let Some(p) = pcm { - t.pcm_chunked = true; - let a = t.audio().cloned().unwrap_or_default(); - let frame_bytes = |frames: u64| -> u64 { - if a.qt_version == 1 && a.bytes_per_frame > 0 && a.samples_per_packet > 0 { - frames / a.samples_per_packet as u64 * a.bytes_per_frame as u64 - } else if const_size > 1 { - frames * const_size as u64 - } else { - frames * p.bytes_per_frame() as u64 - } - }; - if chunks.len() > MAX_SAMPLES { - return Err(Error::TooLarge("chunk count")); - } - samples.reserve(chunks.len()); - for (ci, &off) in chunks.iter().enumerate() { - while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 { - stsc_i += 1; - } - let spc = (stsc[stsc_i].1 as usize).min(sample_count - sidx); - if spc == 0 { - continue; - } - let dur = stts_c.take(spc as u64); - let size = frame_bytes(spc as u64).min(u32::MAX as u64) as u32; - samples.push(Sample { - offset: off, - size, - dts, - pts: dts, - duration: dur.min(u32::MAX as u64) as u32, - is_sync: true, - description_index: stsc[stsc_i].2, - }); - dts = dts.saturating_add(dur.min(i64::MAX as u64) as i64); - sidx += spc; - if sidx >= sample_count { - break; - } - } - } else { - if sample_count > MAX_SAMPLES { - return Err(Error::TooLarge("sample count")); - } - samples.reserve(sample_count); - let mut stss_i = 0usize; - 'chunks: for (ci, &off) in chunks.iter().enumerate() { - while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 { - stsc_i += 1; - } - let (_, spc, sdi) = stsc[stsc_i]; - let mut pos = off; - for _ in 0..spc { - if sidx >= sample_count { - break 'chunks; - } - let size = if const_size != 0 { const_size } else { sizes[sidx] }; - let duration = stts_c.next_value(); - let cts = ctts_c.next_value() as i32 as i64; - let is_sync = match &stss { - None => true, - Some(v) => { - while stss_i < v.len() && (v[stss_i] as usize) < sidx + 1 { - stss_i += 1; - } - stss_i < v.len() && v[stss_i] as usize == sidx + 1 - } - }; - samples.push(Sample { offset: pos, size, dts, pts: dts.saturating_add(cts), duration, is_sync, description_index: sdi }); - pos = pos.saturating_add(size as u64); - dts = dts.saturating_add(duration as i64); - sidx += 1; - } - } - } - t.samples = samples; - Ok(()) -} - -fn parse_moof(moof_off: u64, moof: &[u8], tracks: &mut [Track], trex: &HashMap, next_dts: &mut [i64], file_len: u64) -> Result<()> { - // End of data of the previous traf, for the base-offset default of subsequent trafs. - let mut prev_end: Option = None; - for b in boxes(moof) { - let b = b?; - if b.kind != b"traf" { - continue; - } - let mut tfhd = None; - let mut tfdt = None; - let mut truns = Vec::new(); - for c in boxes(b.payload) { - let c = c?; - match &c.kind.0 { - b"tfhd" => tfhd = Some(c.payload), - b"tfdt" => tfdt = Some(c.payload), - b"trun" => truns.push(c.payload), - _ => {} - } - } - let tfhd = tfhd.ok_or_else(|| Error::Invalid("traf without tfhd".into()))?; - let mut c = Cur::new(tfhd, "tfhd"); - let (_, fl) = c.full_header()?; - let id = c.u32()?; - let Some(ti) = tracks.iter().position(|t| t.id == id) else { continue }; - let d = trex.get(&id).copied().unwrap_or_default(); - let base = if fl & 1 != 0 { - c.u64()? - } else if fl & 0x20000 != 0 { - moof_off - } else { - prev_end.unwrap_or(moof_off) - }; - let sdi = if fl & 2 != 0 { c.u32()? } else { d.description_index }; - let def_dur = if fl & 8 != 0 { c.u32()? } else { d.duration }; - let def_size = if fl & 0x10 != 0 { c.u32()? } else { d.size }; - let def_flags = if fl & 0x20 != 0 { c.u32()? } else { d.flags }; - if let Some(p) = tfdt { - let mut c = Cur::new(p, "tfdt"); - let (v, _) = c.full_header()?; - let t = if v == 1 { c.u64()? } else { c.u32()? as u64 }; - next_dts[ti] = t.min(i64::MAX as u64) as i64; - } - let mut data_pos = base; - for tr in truns { - let mut c = Cur::new(tr, "trun"); - let (v, tf) = c.full_header()?; - let per = [0x100u32, 0x200, 0x400, 0x800].iter().filter(|&&f| tf & f != 0).count() * 4; - let n = c.u32()? as usize; - if per > 0 { - c.check_count(n, per)?; - } else if (n as u64).saturating_mul(def_size.max(1) as u64) > file_len { - return Err(Error::TooLarge("trun sample count")); - } - if tracks[ti].samples.len() + n > MAX_SAMPLES { - return Err(Error::TooLarge("sample count")); - } - if tf & 1 != 0 { - data_pos = base.wrapping_add_signed(c.i32()? as i64); - } - let first_flags = if tf & 4 != 0 { Some(c.u32()?) } else { None }; - let t = &mut tracks[ti]; - t.samples.reserve(n); - for i in 0..n { - let dur = if tf & 0x100 != 0 { c.u32()? } else { def_dur }; - let size = if tf & 0x200 != 0 { c.u32()? } else { def_size }; - let flags = if tf & 0x400 != 0 { c.u32()? } else { def_flags }; - // first_sample_flags overrides the flags of the first sample only. - let flags = if i == 0 { first_flags.unwrap_or(flags) } else { flags }; - let cto = if tf & 0x800 != 0 { - let raw = c.u32()?; - if v == 0 { raw as i64 } else { raw as i32 as i64 } - } else { - 0 - }; - let dts = next_dts[ti]; - t.samples.push(Sample { - offset: data_pos, - size, - dts, - pts: dts.saturating_add(cto), - duration: dur, - is_sync: flags & 0x1_0000 == 0, - description_index: sdi, - }); - data_pos = data_pos.saturating_add(size as u64); - next_dts[ti] = dts.saturating_add(dur as i64); - } - } - prev_end = Some(data_pos); - } - Ok(()) -} - -fn parse_udta(p: &[u8], md: &mut Metadata) { - for b in boxes(p).map_while(|b| b.ok()) { - if b.kind == b"meta" { - parse_meta(b.payload, md); - } else if b.kind.0[0] == 0xA9 && b.payload.len() >= 4 { - let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; - if let Some(s) = b.payload.get(4..4 + n) { - md.entries.push((b.kind.to_string_lossy(), String::from_utf8_lossy(s).into_owned())); - } - } - } -} - -fn parse_meta(p: &[u8], md: &mut Metadata) { - // QuickTime `meta` is a plain box; ISO `meta` is a full box. - let body = if p.len() >= 8 && &p[4..8] == b"hdlr" { p } else { p.get(4..).unwrap_or(&[]) }; - let mut keys: Vec = Vec::new(); - if let Some(k) = find(body, b"keys") { - let mut c = Cur::new(k, "keys"); - if c.full_header().is_ok() - && let Ok(n) = c.count(8) - { - for _ in 0..n { - let Ok(sz) = c.u32() else { break }; - let Ok(_ns) = c.u32() else { break }; - let Ok(name) = c.bytes((sz as usize).saturating_sub(8)) else { break }; - keys.push(String::from_utf8_lossy(name).into_owned()); - } - } - } - let Some(ilst) = find(body, b"ilst") else { return }; - for item in boxes(ilst).map_while(|b| b.ok()) { - let idx = item.kind.as_u32() as usize; - let key = if !keys.is_empty() && idx >= 1 && idx <= keys.len() { keys[idx - 1].clone() } else { item.kind.to_string_lossy() }; - if let Some(d) = find(item.payload, b"data") - && d.len() >= 8 - { - let ty = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) & 0xFF_FFFF; - if ty == 1 || ty == 0 { - md.entries.push((key, String::from_utf8_lossy(&d[8..]).into_owned())); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn language_codes() { - // "und" = 0x55C4 - assert_eq!(parse_language(0x55C4), "und"); - assert_eq!(parse_language(0), "eng"); - assert_eq!(parse_language(0x15C7), "eng"); - } - - #[test] - fn stts_cursor() { - let runs = [(2, 10), (0, 99), (3, 5)]; - let mut c = SttsCursor { runs: &runs, run: 0, used: 0 }; - assert_eq!(c.next_value(), 10); - assert_eq!(c.take(3), 10 + 5 + 5); - assert_eq!(c.next_value(), 5); - assert_eq!(c.next_value(), 0); - } - - #[test] - fn rejects_no_moov_and_cmov() { - let mut b = crate::bytes::BoxBuf::new(); - b.leaf(b"free", &[0; 4]); - assert!(matches!(open(b.buf.clone()), Err(Error::NoMoov))); - let m = b.start(b"moov"); - b.leaf(b"cmov", &[]); - b.end(m); - assert!(matches!(open(b.buf), Err(Error::Unsupported(_)))); - } - - #[test] - fn metadata_qt_and_ilst() { - let mut b = crate::bytes::BoxBuf::new(); - let m = b.start(&[0xA9, b'n', b'a', b'm']); - b.u16(2); - b.u16(0); - b.bytes(b"hi"); - b.end(m); - let meta = b.start_full(b"meta", 0, 0); - b.leaf(b"hdlr", &[0; 24]); - let il = b.start(b"ilst"); - let it = b.start(&[0xA9, b'A', b'R', b'T']); - let d = b.start(b"data"); - b.u32(1); - b.u32(0); - b.bytes(b"me"); - b.end(d); - b.end(it); - b.end(il); - b.end(meta); - let mut md = Metadata::default(); - parse_udta(&b.buf, &mut md); - assert_eq!(md.title(), Some("hi")); - assert_eq!(md.get("©ART"), Some("me")); - } - - use crate::bytes::BoxBuf; - - fn full(b: &mut BoxBuf, kind: &[u8; 4], v: u8, f: impl FnOnce(&mut BoxBuf)) { - let m = b.start_full(kind, v, 0); - f(b); - b.end(m); - } - - /// Hand-built file: uuid/wide/skip boxes, stz2 sizes, co64, ctts v1 with negative offsets, - /// stss, sdtp, sbgp/sgpd, and an edit list with a leading empty edit. - fn handmade(stsz_override: Option<(u32, u32)>) -> Vec { - let mut b = BoxBuf::new(); - let m = b.start(b"ftyp"); - b.bytes(b"isom"); - b.u32(0); - b.bytes(b"isom"); - b.end(m); - let m = b.start(b"uuid"); - b.bytes(&[0xAB; 16]); - b.bytes(b"payload"); - b.end(m); - b.leaf(b"wide", &[]); - b.leaf(b"skip", &[1, 2, 3]); - // mdat with 64-bit size: 4 samples of 3,1,2,4 bytes. - let data_start = b.len() as u64 + 16; - b.u32(1); - b.bytes(b"mdat"); - b.u64(16 + 10); - b.bytes(&[1, 1, 1, 2, 3, 3, 4, 4, 4, 4]); - let moov = b.start(b"moov"); - full(&mut b, b"mvhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(1000); - b.u32(500); - b.zeros(80); - }); - let trak = b.start(b"trak"); - full(&mut b, b"tkhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(7); - b.u32(0); - b.u32(500); - b.zeros(52); - b.u32(320 << 16); - b.u32(240 << 16); - }); - let edts = b.start(b"edts"); - full(&mut b, b"elst", 1, |b| { - b.u32(2); - b.u64(100); - b.i64(-1); - b.i32(0x10000); - b.u64(400); - b.i64(20); - b.i32(0x10000); - }); - b.end(edts); - let mdia = b.start(b"mdia"); - full(&mut b, b"mdhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(100); - b.u32(40); - b.u16(0x15C7); - b.u16(0); - }); - full(&mut b, b"hdlr", 0, |b| { - b.u32(0); - b.bytes(b"vide"); - b.zeros(12); - b.bytes(b"Video\0"); - }); - let minf = b.start(b"minf"); - let stbl = b.start(b"stbl"); - full(&mut b, b"stsd", 0, |b| { - b.u32(1); - crate::codec::SampleEntry::jpeg(320, 240).write(b, false).unwrap(); - }); - full(&mut b, b"stts", 0, |b| { - b.u32(1); - b.u32(4); - b.u32(10); - }); - full(&mut b, b"ctts", 1, |b| { - b.u32(2); - b.u32(2); - b.i32(-5); - b.u32(2); - b.i32(5); - }); - full(&mut b, b"stss", 0, |b| { - b.u32(2); - b.u32(3); - b.u32(1); - }); - full(&mut b, b"stsc", 0, |b| { - b.u32(2); - b.u32(1); - b.u32(3); - b.u32(1); - b.u32(2); - b.u32(1); - b.u32(1); - }); - match stsz_override { - Some((size, count)) => full(&mut b, b"stsz", 0, |b| { - b.u32(size); - b.u32(count); - }), - None => full(&mut b, b"stz2", 0, |b| { - b.u32(4); - b.u32(4); - b.u8(0x31); - b.u8(0x24); - }), - } - full(&mut b, b"co64", 0, |b| { - b.u32(2); - b.u64(data_start); - b.u64(data_start + 6); - }); - full(&mut b, b"sdtp", 0, |b| b.bytes(&[0x20, 0x10, 0x10, 0x10])); - full(&mut b, b"sbgp", 0, |b| { - b.bytes(b"roll"); - b.u32(1); - b.u32(4); - b.u32(1); - }); - full(&mut b, b"sgpd", 1, |b| { - b.bytes(b"roll"); - b.u32(2); - b.u32(1); - b.i16(-1); - }); - b.end(stbl); - b.end(minf); - b.end(mdia); - b.end(trak); - b.end(moov); - // Trailing box extending to EOF (size 0). - b.u32(0); - b.bytes(b"free"); - b.bytes(&[9; 5]); - b.buf - } - - #[test] - fn handmade_sample_table() { - let d = handmade(None); - let f = open(d.as_slice()).unwrap(); - assert_eq!(f.major_brand, *b"isom"); - let t = &f.tracks[0]; - assert_eq!((t.id, t.kind, t.timescale, t.language.as_str()), (7, TrackKind::Video, 100, "eng")); - assert_eq!((t.width, t.height), (320, 240)); - assert_eq!(t.handler_name, "Video"); - let sizes: Vec = t.samples.iter().map(|s| s.size).collect(); - assert_eq!(sizes, [3, 1, 2, 4]); - let offs: Vec = t.samples.iter().map(|s| s.offset - t.samples[0].offset).collect(); - assert_eq!(offs, [0, 3, 4, 6]); - let pts: Vec = t.samples.iter().map(|s| s.pts).collect(); - assert_eq!(pts, [-5, 5, 25, 35]); - let sync: Vec = t.samples.iter().map(|s| s.is_sync).collect(); - assert_eq!(sync, [true, false, true, false]); - for (i, want) in [vec![1u8, 1, 1], vec![2], vec![3, 3], vec![4; 4]].iter().enumerate() { - assert_eq!(&f.read_sample(d.as_slice(), 0, i).unwrap(), want); - } - // Empty edit of 100 movie ticks = 10 media ticks, then media starts at 20. - assert_eq!(t.edits.len(), 2); - assert_eq!(t.edit_offset, 10 - 20); - assert_eq!(t.presentation_pts(2), Some(15)); - assert_eq!(t.sdtp.len(), 4); - assert_eq!(t.sample_groups[0].grouping_type, *b"roll"); - assert_eq!(t.sample_groups[0].descriptions, vec![vec![0xFF, 0xFF]]); - assert!(f.read_sample(d.as_slice(), 0, 4).is_err()); - assert!(f.read_sample(d.as_slice(), 1, 0).is_err()); - } - - #[test] - fn absurd_counts_are_bounded() { - // Constant-size stsz claiming 4 billion samples is clamped to what fits in the file. - let d = handmade(Some((1, u32::MAX))); - let f = open(d.as_slice()).unwrap(); - assert!(f.tracks[0].samples.len() <= d.len()); - // A table claiming more entries than its box holds fails cleanly. - let mut d = handmade(None); - let p = d.windows(4).position(|w| w == b"stts").unwrap(); - d[p + 8..p + 12].copy_from_slice(&u32::MAX.to_be_bytes()); - assert!(matches!(open(d.as_slice()), Err(Error::Truncated(_)))); - // Box larger than its parent. - let mut d = handmade(None); - let p = d.windows(4).position(|w| w == b"stsc").unwrap(); - d[p - 4..p].copy_from_slice(&0x7FFF_FFFFu32.to_be_bytes()); - assert!(open(d.as_slice()).is_err()); - } -} diff --git a/crates/isobmff/src/error.rs b/crates/isobmff/src/error.rs deleted file mode 100644 index b5dfd752f..000000000 --- a/crates/isobmff/src/error.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::io; - -/// Errors produced by the demuxer and muxer. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("I/O error: {0}")] - Io(#[from] io::Error), - /// A box or structure ended before all required fields were read. - #[error("truncated data while reading {0}")] - Truncated(&'static str), - /// Structurally invalid data. - #[error("invalid data: {0}")] - Invalid(String), - /// Valid but unsupported feature (e.g. compressed `cmov` movie header). - #[error("unsupported: {0}")] - Unsupported(String), - /// A count or size exceeds sanity limits relative to the file size. - #[error("size limit exceeded: {0}")] - TooLarge(&'static str), - /// No `moov` box was found. - #[error("no moov box found")] - NoMoov, - /// A track index was out of range. - #[error("track index {0} out of range")] - NoSuchTrack(usize), - /// A sample index was out of range. - #[error("sample index {0} out of range")] - NoSuchSample(usize), - /// Muxer usage error (e.g. invalid track config, bad sample). - #[error("muxer: {0}")] - Mux(String), -} - -pub type Result = std::result::Result; - -pub(crate) fn invalid(msg: impl Into) -> Result { - Err(Error::Invalid(msg.into())) -} diff --git a/crates/isobmff/src/lib.rs b/crates/isobmff/src/lib.rs index 6c0b657d8..50871ab54 100644 --- a/crates/isobmff/src/lib.rs +++ b/crates/isobmff/src/lib.rs @@ -1,28 +1,7 @@ -//! Clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) support. -//! -//! - **Demux:** [`open`] parses box headers plus `moov` (and `moof` for fragmented files) from a -//! [`ByteSource`] and builds per-track [`Sample`] tables; media data is read on demand with -//! [`Mp4File::read_sample`]. -//! - **Mux:** [`Mp4Writer`] writes progressive MP4/MOV (optionally faststart); -//! [`FragmentedWriter`] writes fragmented MP4. -//! -//! See the crate README for supported boxes and limitations. +//! ISO BMFF (MP4) and QuickTime (MOV) demuxer and muxer: a re-export of +//! [`craft_media::isobmff`] from craft-libs. Supported boxes and limitations are in the +//! craft-media README. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -mod bytes; -mod codec; -mod demux; -mod error; -mod mux; -mod source; - -pub use bytes::FourCc; -pub use codec::{ - AacConfig, ApvConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray, - MasteringDisplay, OpusConfig, PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc, -}; -pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open}; -pub use error::{Error, Result}; -pub use mux::{Brand, FragmentedWriter, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; -pub use source::ByteSource; +pub use craft_media::isobmff::*; diff --git a/crates/isobmff/src/mux.rs b/crates/isobmff/src/mux.rs deleted file mode 100644 index 880d49a2c..000000000 --- a/crates/isobmff/src/mux.rs +++ /dev/null @@ -1,963 +0,0 @@ -//! Muxers: progressive MP4/MOV ([`Mp4Writer`], optional faststart) and fragmented MP4 ([`FragmentedWriter`]). - -use crate::bytes::BoxBuf; -use crate::codec::{CodecConfig, SampleEntry, TimecodeConfig}; -use crate::demux::Edit; -use crate::error::{Error, Result}; -use std::io::{Read, Seek, SeekFrom, Write}; - -/// Output flavour: determines brands and some sample-entry encodings. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Brand { - /// ISO MP4: `isom` major brand, compatible `isom iso2 avc1 mp41 mp42`. - Mp4, - /// QuickTime MOV: `qt ` major brand. - Mov, -} - -/// Movie-level writer options. -#[derive(Clone, Debug)] -pub struct WriterOptions { - pub brand: Brand, - /// Movie timescale (`mvhd`); default 1000. - pub movie_timescale: u32, - /// Text metadata written to `moov/udta` (keys like `©nam`, `©ART`, `©cmt`, `©too`). - pub metadata: Vec<(String, String)>, -} - -impl WriterOptions { - pub fn new(brand: Brand) -> Self { - WriterOptions { brand, movie_timescale: 1000, metadata: Vec::new() } - } -} - -/// Per-track configuration. -#[derive(Clone, Debug)] -pub struct TrackConfig { - pub entry: SampleEntry, - /// Media timescale. For PCM it must equal the sample rate (one tick per audio frame). - pub timescale: u32, - /// ISO 639-2/T language (`und` by default). - pub language: String, - /// Explicit edit list (segment durations in the movie timescale). Takes precedence over `media_start`. - pub edits: Vec, - /// Convenience: create one edit starting at this media time (e.g. AAC encoder delay / priming, - /// or the first pts of a B-frame stream) spanning the rest of the media. - pub media_start: Option, - /// `hdlr` name; a sensible default is used when `None`. - pub handler_name: Option, -} - -impl TrackConfig { - pub fn new(entry: SampleEntry, timescale: u32) -> Self { - TrackConfig { entry, timescale, language: "und".into(), edits: Vec::new(), media_start: None, handler_name: None } - } -} - -/// A sample handed to the muxers. -#[derive(Clone, Copy, Debug)] -pub struct WriteSample<'a> { - pub data: &'a [u8], - /// Duration in the media timescale. Ignored for PCM (each frame lasts one tick). - pub duration: u32, - /// pts − dts in the media timescale. - pub composition_offset: i32, - pub is_sync: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Handler { - Video, - Audio, - Timecode, - Other, -} - -fn handler_of(e: &SampleEntry) -> Handler { - if matches!(e.codec, CodecConfig::Timecode(_)) { - Handler::Timecode - } else if e.video.is_some() { - Handler::Video - } else if e.audio.is_some() { - Handler::Audio - } else { - Handler::Other - } -} - -/// Accumulated sample tables for one track. -#[derive(Clone, Debug, Default)] -struct Tables { - stts: Vec<(u32, u32)>, - ctts: Vec<(u32, i32)>, - sizes: Vec, - /// Constant sample size (PCM) — then `sizes` is unused. - const_size: Option, - sample_count: u64, - /// 1-based sync sample numbers. - sync: Vec, - all_sync: bool, - /// (offset relative to file start before shifting, sample count) - chunks: Vec<(u64, u32)>, - media_duration: u64, - /// End of the last sample on the composition timeline (decode time + composition offset + - /// duration, maximum over samples). - comp_end: u64, -} - -impl Tables { - fn new() -> Self { - Tables { all_sync: true, ..Default::default() } - } - fn push_run(runs: &mut Vec<(u32, T)>, count: u32, v: T) { - if let Some(last) = runs.last_mut() - && last.1 == v - && last.0.checked_add(count).is_some() - { - last.0 += count; - return; - } - runs.push((count, v)); - } -} - -struct WTrack { - cfg: TrackConfig, - id: u32, - handler: Handler, - pcm_frame_bytes: Option, - t: Tables, - /// Track id of a linked timecode track (`tref/tmcd`). - tmcd_ref: Option, - /// For timecode tracks: (video track index, start frame). - tmcd_source: Option<(usize, u32)>, -} - -fn pack_language(lang: &str) -> u16 { - let b = lang.as_bytes(); - if b.len() == 3 && b.iter().all(|c| c.is_ascii_lowercase()) { - (((b[0] - 0x60) as u16) << 10) | (((b[1] - 0x60) as u16) << 5) | ((b[2] - 0x60) as u16) - } else { - 0x55C4 // "und" - } -} - -fn rescale(v: u64, from: u32, to: u32) -> u64 { - if from == 0 { - return 0; - } - (v as u128 * to as u128 / from as u128).min(u64::MAX as u128) as u64 -} - -const MATRIX: [i32; 9] = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000]; - -fn write_ftyp(b: &mut BoxBuf, brand: Brand) { - let m = b.start(b"ftyp"); - match brand { - Brand::Mp4 => { - b.bytes(b"isom"); - b.u32(0x200); - for c in [b"isom", b"iso2", b"avc1", b"mp41", b"mp42"] { - b.bytes(c); - } - } - Brand::Mov => { - b.bytes(b"qt "); - b.u32(0x200); - b.bytes(b"qt "); - } - } - b.end(m); -} - -fn movie_duration(tracks: &[WTrack], movie_ts: u32) -> u64 { - tracks.iter().map(|t| presentation_duration(t, movie_ts)).max().unwrap_or(0) -} - -fn effective_edits(t: &WTrack, movie_ts: u32) -> Vec { - if !t.cfg.edits.is_empty() { - return t.cfg.edits.clone(); - } - if let Some(start) = t.cfg.media_start { - // presentation runs from `start` to the end of the last sample in composition order (with - // B-frame reordering that is past the end of the decode timeline) - let rest = t.t.comp_end.max(t.t.media_duration).saturating_sub(start.max(0) as u64); - return vec![Edit { segment_duration: rescale(rest, t.cfg.timescale, movie_ts), media_time: start, media_rate: 0x10000 }]; - } - Vec::new() -} - -fn presentation_duration(t: &WTrack, movie_ts: u32) -> u64 { - let e = effective_edits(t, movie_ts); - if e.is_empty() { rescale(t.t.media_duration, t.cfg.timescale, movie_ts) } else { e.iter().map(|e| e.segment_duration).sum() } -} - -fn write_mvhd(b: &mut BoxBuf, timescale: u32, duration: u64, next_id: u32) { - let v1 = duration > u32::MAX as u64; - let m = b.start_full(b"mvhd", v1 as u8, 0); - if v1 { - b.u64(0); - b.u64(0); - b.u32(timescale); - b.u64(duration); - } else { - b.u32(0); - b.u32(0); - b.u32(timescale); - b.u32(duration as u32); - } - b.u32(0x10000); - b.u16(0x100); - b.zeros(10); - for v in MATRIX { - b.i32(v); - } - b.zeros(24); - b.u32(next_id); - b.end(m); -} - -fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, force_co64: bool) -> Result<()> { - let trak = b.start(b"trak"); - let dur = presentation_duration(t, movie_ts); - let v1 = dur > u32::MAX as u64; - let m = b.start_full(b"tkhd", v1 as u8, 3); - if v1 { - b.u64(0); - b.u64(0); - b.u32(t.id); - b.u32(0); - b.u64(dur); - } else { - b.u32(0); - b.u32(0); - b.u32(t.id); - b.u32(0); - b.u32(dur as u32); - } - b.zeros(8); - b.i16(0); - b.u16(0); - b.i16(if t.handler == Handler::Audio { 0x100 } else { 0 }); - b.u16(0); - for v in MATRIX { - b.i32(v); - } - let (w, h) = match &t.cfg.entry.video { - Some(v) => { - let w = match v.pixel_aspect { - Some((hs, vs)) if hs > 0 && vs > 0 => (v.width as u64 * hs as u64 / vs as u64) as u32, - _ => v.width as u32, - }; - (w, v.height as u32) - } - None => (0, 0), - }; - b.u32(w << 16); - b.u32(h << 16); - b.end(m); - let edits = effective_edits(t, movie_ts); - if !edits.is_empty() { - let e = b.start(b"edts"); - let v1 = edits.iter().any(|e| e.segment_duration > u32::MAX as u64 || e.media_time > i32::MAX as i64 || e.media_time < i32::MIN as i64); - let l = b.start_full(b"elst", v1 as u8, 0); - b.u32(edits.len() as u32); - for ed in &edits { - if v1 { - b.u64(ed.segment_duration); - b.i64(ed.media_time); - } else { - b.u32(ed.segment_duration as u32); - b.i32(ed.media_time as i32); - } - b.i32(ed.media_rate); - } - b.end(l); - b.end(e); - } - if let Some(id) = t.tmcd_ref { - let r = b.start(b"tref"); - let m = b.start(b"tmcd"); - b.u32(id); - b.end(m); - b.end(r); - } - let mdia = b.start(b"mdia"); - let mv1 = t.t.media_duration > u32::MAX as u64; - let m = b.start_full(b"mdhd", mv1 as u8, 0); - if mv1 { - b.u64(0); - b.u64(0); - b.u32(t.cfg.timescale); - b.u64(t.t.media_duration); - } else { - b.u32(0); - b.u32(0); - b.u32(t.cfg.timescale); - b.u32(t.t.media_duration as u32); - } - b.u16(pack_language(&t.cfg.language)); - b.u16(0); - b.end(m); - let (htype, default_name) = match t.handler { - Handler::Video => (b"vide", "VideoHandler"), - Handler::Audio => (b"soun", "SoundHandler"), - Handler::Timecode => (b"tmcd", "TimeCodeHandler"), - Handler::Other => (b"meta", "MetaHandler"), - }; - let name = t.cfg.handler_name.clone().unwrap_or_else(|| default_name.into()); - let m = b.start_full(b"hdlr", 0, 0); - b.bytes(if qt { b"mhlr" } else { &[0, 0, 0, 0] }); - b.bytes(htype); - b.zeros(12); - if qt { - let n = name.len().min(255); - b.u8(n as u8); - b.bytes(&name.as_bytes()[..n]); - } else { - b.bytes(name.as_bytes()); - b.u8(0); - } - b.end(m); - let minf = b.start(b"minf"); - match t.handler { - Handler::Video => { - let m = b.start_full(b"vmhd", 0, 1); - b.zeros(8); - b.end(m); - } - Handler::Audio => { - let m = b.start_full(b"smhd", 0, 0); - b.zeros(4); - b.end(m); - } - Handler::Timecode => { - let g = b.start(b"gmhd"); - let m = b.start_full(b"gmin", 0, 0); - b.u16(0x40); - b.u16(0x8000); - b.u16(0x8000); - b.u16(0x8000); - b.i16(0); - b.u16(0); - b.end(m); - let tm = b.start(b"tmcd"); - let m = b.start_full(b"tcmi", 0, 0); - b.u16(0); - b.u16(0); - b.u16(12); - b.u16(0); - b.bytes(&[0xFF; 6]); - b.zeros(6); - b.u8(0); - b.end(m); - b.end(tm); - b.end(g); - } - Handler::Other => { - let m = b.start_full(b"nmhd", 0, 0); - b.end(m); - } - } - if qt { - // QuickTime data handler reference. - let m = b.start_full(b"hdlr", 0, 0); - b.bytes(b"dhlr"); - b.bytes(b"url "); - b.zeros(12); - b.u8(0); - b.end(m); - } - let d = b.start(b"dinf"); - let r = b.start_full(b"dref", 0, 0); - b.u32(1); - let u = b.start_full(b"url ", 0, 1); - b.end(u); - b.end(r); - b.end(d); - let stbl = b.start(b"stbl"); - let m = b.start_full(b"stsd", 0, 0); - b.u32(1); - t.cfg.entry.write(b, qt)?; - b.end(m); - let tb = &t.t; - let m = b.start_full(b"stts", 0, 0); - b.u32(tb.stts.len() as u32); - for &(c, d) in &tb.stts { - b.u32(c); - b.u32(d); - } - b.end(m); - if tb.ctts.iter().any(|&(_, o)| o != 0) { - let neg = tb.ctts.iter().any(|&(_, o)| o < 0); - let m = b.start_full(b"ctts", neg as u8, 0); - b.u32(tb.ctts.len() as u32); - for &(c, o) in &tb.ctts { - b.u32(c); - b.i32(o); - } - b.end(m); - } - if !tb.all_sync { - let m = b.start_full(b"stss", 0, 0); - b.u32(tb.sync.len() as u32); - for &s in &tb.sync { - b.u32(s); - } - b.end(m); - } - let m = b.start_full(b"stsc", 0, 0); - let mut runs: Vec<(u32, u32)> = Vec::new(); - for (i, &(_, n)) in tb.chunks.iter().enumerate() { - if runs.last().is_none_or(|r| r.1 != n) { - runs.push((i as u32 + 1, n)); - } - } - b.u32(runs.len() as u32); - for (first, n) in runs { - b.u32(first); - b.u32(n); - b.u32(1); - } - b.end(m); - let m = b.start_full(b"stsz", 0, 0); - match tb.const_size { - Some(s) => { - b.u32(s); - b.u32(tb.sample_count.min(u32::MAX as u64) as u32); - } - None => { - let first = tb.sizes.first().copied().unwrap_or(0); - // A uniform size of 0 would mean "table follows" (ISO/IEC 14496-12 §8.7.3), so - // all-empty samples must still be written as a table. - if first != 0 && !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) { - b.u32(first); - b.u32(tb.sizes.len() as u32); - } else { - b.u32(0); - b.u32(tb.sizes.len() as u32); - for &s in &tb.sizes { - b.u32(s); - } - } - } - } - b.end(m); - let co64 = force_co64 || tb.chunks.iter().any(|&(o, _)| o + shift > u32::MAX as u64); - let m = b.start_full(if co64 { b"co64" } else { b"stco" }, 0, 0); - b.u32(tb.chunks.len() as u32); - for &(o, _) in &tb.chunks { - if co64 { - b.u64(o + shift); - } else { - b.u32((o + shift) as u32); - } - } - b.end(m); - b.end(stbl); - b.end(minf); - b.end(mdia); - b.end(trak); - Ok(()) -} - -fn write_udta(b: &mut BoxBuf, md: &[(String, String)]) { - let items: Vec<([u8; 4], &str)> = md - .iter() - .filter_map(|(k, v)| { - let mut chars = k.chars(); - if chars.next()? != '©' { - return None; - } - let rest: Vec = chars.map(|c| c as u32 as u8).collect(); - (rest.len() == 3).then(|| ([0xA9, rest[0], rest[1], rest[2]], v.as_str())) - }) - .collect(); - if items.is_empty() { - return; - } - let u = b.start(b"udta"); - for (k, v) in items { - let m = b.start(&k); - b.u16(v.len() as u16); - b.u16(0x55C4); - b.bytes(v.as_bytes()); - b.end(m); - } - b.end(u); -} - -fn validate_track(cfg: &TrackConfig) -> Result> { - if cfg.timescale == 0 { - return Err(Error::Mux("timescale must be non-zero".into())); - } - if let CodecConfig::Pcm(p) = &cfg.entry.codec { - if p.bits == 0 || p.channels == 0 { - return Err(Error::Mux("PCM needs bits and channels".into())); - } - if (p.sample_rate - cfg.timescale as f64).abs() > 0.5 { - return Err(Error::Mux("PCM track timescale must equal the sample rate".into())); - } - return Ok(Some(p.bytes_per_frame())); - } - Ok(None) -} - -/// Progressive MP4/MOV writer: `ftyp`, then one `mdat` streamed to `W`, then `moov` at [`finish`](Self::finish) -/// (or moved in front of `mdat` by [`finish_faststart`](Self::finish_faststart)). -pub struct Mp4Writer { - w: W, - opts: WriterOptions, - tracks: Vec, - /// Offset of the placeholder box (8 bytes) preceding the mdat header. - pad_pos: u64, - mdat_pos: u64, - pos: u64, - last_track: Option, -} - -impl Mp4Writer { - /// Start a file: writes `ftyp`, a placeholder box and the `mdat` header. - pub fn new(mut w: W, opts: WriterOptions) -> Result { - let start = w.stream_position()?; - let mut b = BoxBuf::new(); - write_ftyp(&mut b, opts.brand); - let pad_pos = start + b.len() as u64; - b.leaf(if opts.brand == Brand::Mov { b"wide" } else { b"free" }, &[]); - let mdat_pos = start + b.len() as u64; - b.leaf(b"mdat", &[]); - w.write_all(&b.buf)?; - let pos = start + b.len() as u64; - Ok(Mp4Writer { w, opts, tracks: Vec::new(), pad_pos, mdat_pos, pos, last_track: None }) - } - - /// Add a track; returns its index (track ids are index + 1). - pub fn add_track(&mut self, cfg: TrackConfig) -> Result { - let pcm = validate_track(&cfg)?; - let handler = handler_of(&cfg.entry); - let mut t = Tables::new(); - t.const_size = pcm; - self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: pcm, t, cfg, tmcd_ref: None, tmcd_source: None }); - Ok(self.tracks.len() - 1) - } - - /// Replace a track's sample entry before the file is finished (the `moov` is written last), for - /// codecs whose configuration is only complete at the end, like the FLAC STREAMINFO in `dfLa`. - pub fn set_sample_entry(&mut self, track: usize, entry: SampleEntry) -> Result<()> { - let t = self.tracks.get_mut(track).ok_or_else(|| Error::Invalid(format!("no track {track}")))?; - t.cfg.entry = entry; - Ok(()) - } - - /// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start - /// frame number) is written at finish, spanning the video track's duration. - pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result { - if video_track >= self.tracks.len() { - return Err(Error::NoSuchTrack(video_track)); - } - if tc.timescale == 0 || tc.frame_duration == 0 || tc.frames_per_second == 0 { - return Err(Error::Mux("timecode needs timescale, frame duration and fps".into())); - } - let entry = SampleEntry { - format: crate::bytes::FourCc(*b"tmcd"), - data_reference_index: 1, - codec: CodecConfig::Timecode(tc.clone()), - video: None, - audio: None, - bitrate: None, - }; - let idx = self.add_track(TrackConfig::new(entry, tc.timescale))?; - self.tracks[idx].tmcd_source = Some((video_track, start_frame)); - let id = self.tracks[idx].id; - self.tracks[video_track].tmcd_ref = Some(id); - Ok(idx) - } - - /// Append one sample to track `track` (decode order). For PCM tracks `data` may hold any whole - /// number of audio frames. - pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> { - let tr = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - if tr.tmcd_source.is_some() { - return Err(Error::Mux("timecode track samples are written automatically".into())); - } - let (count, dur) = match tr.pcm_frame_bytes { - Some(fb) => { - if !s.data.len().is_multiple_of(fb as usize) { - return Err(Error::Mux("PCM data is not a whole number of frames".into())); - } - ((s.data.len() / fb as usize) as u32, 1u32) - } - None => { - if s.data.len() > u32::MAX as usize { - return Err(Error::Mux("sample larger than 4 GiB".into())); - } - (1, s.duration) - } - }; - if count == 0 { - return Ok(()); - } - self.w.write_all(s.data)?; - let offset = self.pos; - self.pos += s.data.len() as u64; - let contiguous = self.last_track == Some(track); - self.last_track = Some(track); - let tr = &mut self.tracks[track]; - let t = &mut tr.t; - Tables::push_run(&mut t.stts, count, dur); - Tables::push_run(&mut t.ctts, count, if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset }); - if tr.pcm_frame_bytes.is_none() { - t.sizes.push(s.data.len() as u32); - if s.is_sync { - t.sync.push(t.sample_count as u32 + 1); - } else { - t.all_sync = false; - } - } - t.sample_count += count as u64; - let ct = if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset as i64 }; - t.comp_end = t.comp_end.max((t.media_duration as i64 + ct + count as i64 * dur as i64).max(0) as u64); - t.media_duration += count as u64 * dur as u64; - match t.chunks.last_mut() { - Some(c) if contiguous => c.1 += count, - _ => t.chunks.push((offset, count)), - } - Ok(()) - } - - fn finish_mdat(&mut self) -> Result<()> { - // Timecode samples. - for i in 0..self.tracks.len() { - let Some((vi, start)) = self.tracks[i].tmcd_source else { continue }; - let v = &self.tracks[vi]; - let vd = v.t.media_duration; - let vts = v.cfg.timescale; - let tts = self.tracks[i].cfg.timescale; - let tc_dur = match &self.tracks[i].cfg.entry.codec { - CodecConfig::Timecode(tc) => tc.frame_duration, - _ => 1, - }; - let d = rescale(vd, vts, tts).max(tc_dur as u64).min(u32::MAX as u64) as u32; - self.w.write_all(&start.to_be_bytes())?; - let off = self.pos; - self.pos += 4; - self.last_track = None; - let t = &mut self.tracks[i].t; - t.stts.push((1, d)); - t.sizes.push(4); - t.sync.push(1); - t.sample_count = 1; - t.media_duration = d as u64; - t.chunks.push((off, 1)); - } - let size = self.pos - self.mdat_pos; - if size <= u32::MAX as u64 { - self.w.seek(SeekFrom::Start(self.mdat_pos))?; - self.w.write_all(&(size as u32).to_be_bytes())?; - } else { - // Merge the placeholder into a 16-byte large-size mdat header. - let mut h = [0u8; 16]; - h[..4].copy_from_slice(&1u32.to_be_bytes()); - h[4..8].copy_from_slice(b"mdat"); - h[8..].copy_from_slice(&(self.pos - self.pad_pos).to_be_bytes()); - self.w.seek(SeekFrom::Start(self.pad_pos))?; - self.w.write_all(&h)?; - } - self.w.seek(SeekFrom::Start(self.pos))?; - Ok(()) - } - - fn build_moov(&self, shift: u64, force_co64: bool) -> Result> { - let qt = self.opts.brand == Brand::Mov; - let ts = self.opts.movie_timescale.max(1); - let mut b = BoxBuf::new(); - let m = b.start(b"moov"); - write_mvhd(&mut b, ts, movie_duration(&self.tracks, ts), self.tracks.len() as u32 + 1); - for t in &self.tracks { - write_trak(&mut b, t, ts, qt, shift, force_co64)?; - } - write_udta(&mut b, &self.opts.metadata); - b.end(m); - Ok(b.buf) - } - - /// Finish the file with `moov` after `mdat`. Returns the underlying writer. - pub fn finish(mut self) -> Result { - self.finish_mdat()?; - let moov = self.build_moov(0, false)?; - self.w.write_all(&moov)?; - self.w.flush()?; - Ok(self.w) - } -} - -impl Mp4Writer { - /// Finish with `moov` in front of `mdat` ("fast start"): media data is shifted forward in place. - pub fn finish_faststart(mut self) -> Result { - self.finish_mdat()?; - let mut moov = self.build_moov(0, false)?; - let mut force = false; - loop { - let shift = moov.len() as u64; - let next = self.build_moov(shift, force)?; - if next.len() == moov.len() { - moov = next; - break; - } - // Offsets crossed the 32-bit boundary: switch to co64 (size only grows, so this converges). - force = true; - moov = next; - } - let shift = moov.len() as u64; - let start = self.pad_pos; - let end = self.pos; - let mut buf = vec![0u8; 1 << 20]; - let mut cur = end; - while cur > start { - let n = (cur - start).min(buf.len() as u64) as usize; - cur -= n as u64; - self.w.seek(SeekFrom::Start(cur))?; - self.w.read_exact(&mut buf[..n])?; - self.w.seek(SeekFrom::Start(cur + shift))?; - self.w.write_all(&buf[..n])?; - } - self.w.seek(SeekFrom::Start(start))?; - self.w.write_all(&moov)?; - self.w.seek(SeekFrom::Start(end + shift))?; - self.w.flush()?; - Ok(self.w) - } -} - -/// Buffered fragment sample: (data, duration, composition offset, sync). -type PendingSample = (Vec, u32, i32, bool); - -/// Fragmented MP4 writer: an init segment (`ftyp` + `moov` with `mvex`) followed by -/// `moof`+`mdat` fragments. Samples are buffered until [`flush_fragment`](Self::flush_fragment). -pub struct FragmentedWriter { - w: W, - opts: WriterOptions, - tracks: Vec, - pending: Vec>, - next_dts: Vec, - seq: u32, - init_written: bool, -} - -impl FragmentedWriter { - pub fn new(w: W, opts: WriterOptions) -> Self { - FragmentedWriter { w, opts, tracks: Vec::new(), pending: Vec::new(), next_dts: Vec::new(), seq: 0, init_written: false } - } - - /// Add a track (before the first fragment). PCM is not supported in fragments. - pub fn add_track(&mut self, cfg: TrackConfig) -> Result { - if self.init_written { - return Err(Error::Mux("tracks must be added before the first fragment".into())); - } - if validate_track(&cfg)?.is_some() { - return Err(Error::Mux("PCM is not supported by the fragmented writer".into())); - } - let handler = handler_of(&cfg.entry); - self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: None, t: Tables::new(), cfg, tmcd_ref: None, tmcd_source: None }); - self.pending.push(Vec::new()); - self.next_dts.push(0); - Ok(self.tracks.len() - 1) - } - - fn write_init(&mut self) -> Result<()> { - let qt = self.opts.brand == Brand::Mov; - let ts = self.opts.movie_timescale.max(1); - let mut b = BoxBuf::new(); - write_ftyp(&mut b, self.opts.brand); - let m = b.start(b"moov"); - write_mvhd(&mut b, ts, 0, self.tracks.len() as u32 + 1); - for t in &self.tracks { - write_trak(&mut b, t, ts, qt, 0, false)?; - } - let mv = b.start(b"mvex"); - for t in &self.tracks { - let x = b.start_full(b"trex", 0, 0); - b.u32(t.id); - b.u32(1); - b.u32(0); - b.u32(0); - b.u32(0); - b.end(x); - } - b.end(mv); - write_udta(&mut b, &self.opts.metadata); - b.end(m); - self.w.write_all(&b.buf)?; - self.init_written = true; - Ok(()) - } - - /// Buffer a sample for the next fragment. - pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> { - let p = self.pending.get_mut(track).ok_or(Error::NoSuchTrack(track))?; - if s.data.len() > u32::MAX as usize { - return Err(Error::Mux("sample larger than 4 GiB".into())); - } - p.push((s.data.to_vec(), s.duration, s.composition_offset, s.is_sync)); - Ok(()) - } - - fn build_moof(&self, data_offsets: &[i32]) -> Vec { - let mut b = BoxBuf::new(); - let m = b.start(b"moof"); - let h = b.start_full(b"mfhd", 0, 0); - b.u32(self.seq); - b.end(h); - for (i, t) in self.tracks.iter().enumerate() { - let p = &self.pending[i]; - if p.is_empty() { - continue; - } - let tf = b.start(b"traf"); - let h = b.start_full(b"tfhd", 0, 0x20000); - b.u32(t.id); - b.end(h); - let h = b.start_full(b"tfdt", 1, 0); - b.u64(self.next_dts[i]); - b.end(h); - let neg = p.iter().any(|s| s.2 < 0); - let h = b.start_full(b"trun", neg as u8, 0x1 | 0x100 | 0x200 | 0x400 | 0x800); - b.u32(p.len() as u32); - b.i32(data_offsets[i]); - for (d, dur, cto, sync) in p { - b.u32(*dur); - b.u32(d.len() as u32); - b.u32(if *sync { 0x0200_0000 } else { 0x0101_0000 }); - b.i32(*cto); - } - b.end(h); - b.end(tf); - } - b.end(m); - b.buf - } - - /// Write all buffered samples as one `moof` + `mdat` fragment (no-op if nothing is buffered). - pub fn flush_fragment(&mut self) -> Result<()> { - if !self.init_written { - self.write_init()?; - } - if self.pending.iter().all(|p| p.is_empty()) { - return Ok(()); - } - self.seq += 1; - let probe = self.build_moof(&vec![0; self.tracks.len()]); - let data_len: u64 = self.pending.iter().flatten().map(|s| s.0.len() as u64).sum(); - let large = data_len + 8 > u32::MAX as u64; - let hdr = if large { 16 } else { 8 }; - let mut offs = Vec::with_capacity(self.tracks.len()); - let mut acc = probe.len() as u64 + hdr; - for p in &self.pending { - offs.push(i32::try_from(acc).map_err(|_| Error::Mux("fragment too large".into()))?); - acc += p.iter().map(|s| s.0.len() as u64).sum::(); - } - let moof = self.build_moof(&offs); - self.w.write_all(&moof)?; - if large { - self.w.write_all(&1u32.to_be_bytes())?; - self.w.write_all(b"mdat")?; - self.w.write_all(&(data_len + 16).to_be_bytes())?; - } else { - self.w.write_all(&((data_len + 8) as u32).to_be_bytes())?; - self.w.write_all(b"mdat")?; - } - for (i, p) in self.pending.iter_mut().enumerate() { - for (d, dur, _, _) in p.drain(..) { - self.w.write_all(&d)?; - self.next_dts[i] += dur as u64; - } - } - Ok(()) - } - - /// Flush remaining samples and return the writer. - pub fn finish(mut self) -> Result { - self.flush_fragment()?; - self.w.flush()?; - Ok(self.w) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codec::{AvcConfig, PcmConfig}; - use std::io::Cursor; - - fn avc_entry() -> SampleEntry { - SampleEntry::avc(AvcConfig::new(vec![vec![0x67, 0x42, 0, 0x1E]], vec![vec![0x68, 0xCE]], 4), 64, 48) - } - - #[test] - fn language_pack() { - assert_eq!(pack_language("und"), 0x55C4); - assert_eq!(pack_language("eng"), 0x15C7); - assert_eq!(pack_language("EN"), 0x55C4); - } - - #[test] - fn simple_roundtrip_and_faststart() { - for fast in [false, true] { - let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); - let v = w.add_track(TrackConfig::new(avc_entry(), 12800)).unwrap(); - let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 2, sample_rate: 48000.0 }; - let a = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap(); - for i in 0..10u8 { - let data = vec![i; 100 + i as usize]; - w.write_sample(v, WriteSample { data: &data, duration: 512, composition_offset: if i % 2 == 0 { 1024 } else { 0 }, is_sync: i % 5 == 0 }) - .unwrap(); - w.write_sample(a, WriteSample { data: &[i; 400], duration: 0, composition_offset: 0, is_sync: true }).unwrap(); - } - let out = if fast { w.finish_faststart() } else { w.finish() }.unwrap().into_inner(); - let f = crate::open(out.as_slice()).unwrap(); - assert_eq!(f.tracks.len(), 2); - let t = &f.tracks[0]; - assert_eq!(t.samples.len(), 10); - for (i, s) in t.samples.iter().enumerate() { - assert_eq!(s.size as usize, 100 + i); - assert_eq!(s.dts, 512 * i as i64); - assert_eq!(s.is_sync, i % 5 == 0); - assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 100 + i]); - } - let at = &f.tracks[1]; - assert!(at.pcm_chunked); - assert_eq!(at.samples.len(), 10); - assert_eq!(at.samples[3].size, 400); - assert_eq!(at.samples[3].duration, 100); - assert_eq!(f.read_sample(out.as_slice(), 1, 3).unwrap(), vec![3u8; 400]); - if fast { - assert!(f.moov_offset < t.samples[0].offset); - } - } - } - - #[test] - fn fragmented_roundtrip() { - let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4)); - let v = w.add_track(TrackConfig::new(avc_entry(), 1000)).unwrap(); - for i in 0..6u8 { - w.write_sample(v, WriteSample { data: &[i; 10], duration: 40, composition_offset: -(i as i32 % 2), is_sync: i % 3 == 0 }).unwrap(); - if i % 3 == 2 { - w.flush_fragment().unwrap(); - } - } - let out = w.finish().unwrap(); - let f = crate::open(out.as_slice()).unwrap(); - assert!(f.fragmented); - assert_eq!(f.fragment_count, 2); - let t = &f.tracks[0]; - assert_eq!(t.samples.len(), 6); - for (i, s) in t.samples.iter().enumerate() { - assert_eq!(s.dts, 40 * i as i64); - assert_eq!(s.pts, s.dts - (i as i64 % 2)); - assert_eq!(s.is_sync, i % 3 == 0); - assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 10]); - } - assert_eq!(t.sync_sample_before(5), 3); - } -} diff --git a/crates/isobmff/src/source.rs b/crates/isobmff/src/source.rs deleted file mode 100644 index 0dad82b20..000000000 --- a/crates/isobmff/src/source.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Random-access byte sources for the demuxer. - -use std::io; -use std::sync::Arc; - -/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). -/// -/// Implementations must be able to serve reads at arbitrary offsets through `&self`. -pub trait ByteSource { - /// Total length in bytes. - fn len(&self) -> u64; - /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - /// True if the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -fn eof() -> io::Error { - io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") -} - -impl ByteSource for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let start = usize::try_from(offset).map_err(|_| eof())?; - let end = start.checked_add(buf.len()).ok_or_else(eof)?; - let src = self.get(start..end).ok_or_else(eof)?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ByteSource for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ByteSource for &T { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Arc { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Box { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl ByteSource for std::fs::File { - fn len(&self) -> u64 { - self.metadata().map(|m| m.len()).unwrap_or(0) - } - #[cfg(unix)] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) - } - #[cfg(windows)] - fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { - use std::os::windows::fs::FileExt; - while !buf.is_empty() { - let n = self.seek_read(buf, offset)?; - if n == 0 { - return Err(eof()); - } - offset += n as u64; - buf = &mut buf[n..]; - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - use std::io::{Read, Seek, SeekFrom}; - let mut f = self; - f.seek(SeekFrom::Start(offset))?; - f.read_exact(buf) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_reads() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(1, &mut b).unwrap(); - assert_eq!(b, [2, 3]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let a: Arc<[u8]> = Arc::from(v.clone()); - a.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - assert_eq!(ByteSource::len(&a), 4); - let s: &[u8] = &v; - assert_eq!(ByteSource::len(&s), 4); - } -} diff --git a/crates/isobmff/tests/common/mod.rs b/crates/isobmff/tests/it/common/mod.rs similarity index 100% rename from crates/isobmff/tests/common/mod.rs rename to crates/isobmff/tests/it/common/mod.rs diff --git a/crates/isobmff/tests/fuzz.rs b/crates/isobmff/tests/it/fuzz.rs similarity index 99% rename from crates/isobmff/tests/fuzz.rs rename to crates/isobmff/tests/it/fuzz.rs index b6d2fcea6..17c376a5e 100644 --- a/crates/isobmff/tests/fuzz.rs +++ b/crates/isobmff/tests/it/fuzz.rs @@ -1,6 +1,6 @@ //! Deterministic mutation fuzzing: the demuxer must never panic on corrupted input. -mod common; +use crate::common; use filmcraft_isobmff::*; use std::io::Cursor; diff --git a/crates/isobmff/tests/it/main.rs b/crates/isobmff/tests/it/main.rs new file mode 100644 index 000000000..48e623e19 --- /dev/null +++ b/crates/isobmff/tests/it/main.rs @@ -0,0 +1,5 @@ +mod common; +mod fuzz; +mod oracle_demux; +mod oracle_mux; +mod roundtrip; diff --git a/crates/isobmff/tests/oracle_demux.rs b/crates/isobmff/tests/it/oracle_demux.rs similarity index 99% rename from crates/isobmff/tests/oracle_demux.rs rename to crates/isobmff/tests/it/oracle_demux.rs index 45ffe3b58..9e2cd0b28 100644 --- a/crates/isobmff/tests/oracle_demux.rs +++ b/crates/isobmff/tests/it/oracle_demux.rs @@ -1,6 +1,6 @@ //! Oracle tests: compare our demuxer's sample tables and codec parameters with ffprobe. -mod common; +use crate::common; use common::*; use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind, open}; diff --git a/crates/isobmff/tests/oracle_mux.rs b/crates/isobmff/tests/it/oracle_mux.rs similarity index 99% rename from crates/isobmff/tests/oracle_mux.rs rename to crates/isobmff/tests/it/oracle_mux.rs index 4b9144241..5383211f6 100644 --- a/crates/isobmff/tests/oracle_mux.rs +++ b/crates/isobmff/tests/it/oracle_mux.rs @@ -1,6 +1,6 @@ //! Muxer oracle tests: remux ffmpeg-made files with our writers and validate with ffmpeg/ffprobe. -mod common; +use crate::common; use common::*; use filmcraft_isobmff::*; diff --git a/crates/isobmff/tests/roundtrip.rs b/crates/isobmff/tests/it/roundtrip.rs similarity index 100% rename from crates/isobmff/tests/roundtrip.rs rename to crates/isobmff/tests/it/roundtrip.rs diff --git a/crates/matroska/Cargo.toml b/crates/matroska/Cargo.toml index 9f64162fb..1cebf9d31 100644 --- a/crates/matroska/Cargo.toml +++ b/crates/matroska/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-media = { workspace = true, features = ["matroska"] } [dev-dependencies] filmcraft-testkit = { workspace = true } diff --git a/crates/matroska/README.md b/crates/matroska/README.md index 40210ad0a..47b9cb623 100644 --- a/crates/matroska/README.md +++ b/crates/matroska/README.md @@ -1,115 +1,3 @@ # filmcraft-matroska -A clean-room Matroska (MKV) and WebM demuxer, plus a minimal muxer, written from the public -specifications: RFC 8794 (EBML), RFC 9559 (Matroska), the Matroska codec mappings and the WebM -container guidelines. Layer L0: it depends only on `std`, has no `unsafe`, and builds for -`wasm32-unknown-unknown`. No GPL/LGPL code (FFmpeg, libmatroska, mkvtoolnix) was consulted; -FFmpeg is used only as an external test oracle. - -## Demuxing - -```rust -use filmcraft_matroska::{Demuxer, TrackKind, open}; - -// Sample tables, same shape as filmcraft-isobmff (any ByteSource: &[u8], Vec, Arc<[u8]>, File…) -let file = std::fs::File::open("clip.mkv")?; -let mkv = open(&file)?; -let v = mkv.track_of_kind(TrackKind::Video).unwrap(); -let k = mkv.keyframe_before(v, 2_000_000_000).unwrap(); // sample index -let frame = mkv.read_sample(&file, v, k)?; - -// Streaming packets + seeking (from a ByteSource, any Read + Seek, or a byte slice) -let mut d = Demuxer::from_reader(std::io::BufReader::new(std::fs::File::open("clip.webm")?))?; -let sp = d.seek(v, 2_000_000_000)?; // lands on the keyframe with pts ≤ 2 s -while let Some(p) = d.next_packet()? { // file order, all tracks - // p.track, p.pts (ticks), p.pts_ns, p.duration, p.keyframe, p.data -} -``` - -- **Open** (`open`, `open_with(src, &OpenOptions { index, verify_crc })`): EBML header (DocType - `matroska`/`webm`), the first Segment, and its level-1 elements: SeekHead (followed for elements - after the clusters), Info, Tracks, Cues, Chapters, Tags, Attachments (listed; payload located by - offset/size, never read). With `index: true` (default) every Cluster is scanned — element and block - headers only, never frame payloads — to build per-track `Sample` tables (`offset`, `size`, `pts`, - `duration`, `keyframe`, cluster, block offset, lace index) and keyframe lists. With `index: false` - open stops at the first Cluster. -- **Timestamps.** Matroska ticks are `TimestampScale` ns (usually 1 ms); `Track::timebase` is that - as a reduced fraction of a second. `pts` is in ticks, `pts_ns` in exact nanoseconds. `CodecDelay` - is subtracted (RFC 9559), as FFmpeg does (Opus/Vorbis priming gives negative pts). Laced frames get - `block ts + i × (BlockDuration or DefaultDuration)/n`; if neither is known, later laces repeat the - block timestamp and have `duration == 0` (`Packet::lace` tells which). -- **Keyframes:** SimpleBlock keyframe flag; BlockGroup = keyframe iff it has no ReferenceBlock. -- **Lacing:** Xiph, fixed-size, EBML. **Unknown sizes:** Segment (live/streamed output) and - Clusters end at the next top-level element. **CRC-32:** optional verification of level-1 - elements and clusters; mismatches go to `MkvFile::crc_errors` (not fatal). **Void** skipped. -- **Damage:** undecodable/oversized elements trigger a byte scan for the next plausible level-1 - element (Cluster whose first child is Timestamp, or a sized metadata element); a truncated final - block is dropped. Problems are listed in `MkvFile::warnings`. -- **Seeking** (`Demuxer::seek(track, time_ns)`): uses the sample index when present; otherwise Cues - (refined by scanning the cued cluster for the exact keyframe); otherwise builds the index by - scanning all clusters. `Demuxer::keyframe_index(track)` returns the keyframe list. -- **Tracks:** number/UID/type/flags, `DefaultDuration`, name, language (+BCP 47), CodecID, - CodecPrivate, CodecName, `CodecDelay`, `SeekPreRoll`; `VideoInfo` (pixel/display size, crop, - interlacing, stereo, alpha mode, `pixel_aspect()`), `Colour` (matrix, range, transfer, primaries, - subsampling/siting, MaxCLL/MaxFALL, SMPTE 2086 mastering metadata); `AudioInfo` (rate, output - rate, channels, bit depth); `ContentEncoding`s. -- **Content encodings:** header stripping is undone in every packet / `read_sample`. zlib/bzlib/ - lzo compression and encryption are reported (`Track::frames_readable() == false`); packets are - then returned as stored and `read_sample` fails with `Unsupported`. - -### Codec mapping (`Codec`) - -| CodecID | `Codec` | payload | -|---|---|---| -| `V_MPEG4/ISO/AVC` | `Avc` | avcC (CodecPrivate) | -| `V_MPEGH/ISO/HEVC` | `Hevc` | hvcC | -| `V_VP8`, `V_VP9` | `Vp8`, `Vp9` | VP9 CodecPrivate features | -| `V_AV1` | `Av1` | av1C | -| `V_PRORES` | `ProRes` | FourCC; frames get the 8-byte `size`+`icpf` header re-added | -| `V_MJPEG`, `V_MS/VFW/FOURCC` (MJPG) | `Mjpeg` | | -| `V_MS/VFW/FOURCC` (other) | `VfwFourcc` | BITMAPINFOHEADER | -| `A_AAC`, legacy `A_AAC/MPEG{2,4}/…` | `Aac` | AudioSpecificConfig (synthesised for legacy IDs, incl. SBR) | -| `A_OPUS` | `Opus` | OpusHead | -| `A_VORBIS` | `Vorbis` | three headers split from Xiph-laced CodecPrivate | -| `A_FLAC` | `Flac` | `fLaC` + metadata blocks | -| `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE` | `Pcm` | bit depth from Audio | -| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2`, `A_MPEG/L1` | `Ac3`, `Eac3`, `Mp3`, `Mp2`, `Mp1` | | -| `S_TEXT/UTF8` | `SubRip` | | -| `S_TEXT/WEBVTT`, `D_WEBVTT/*` (WebM) | `WebVtt` | WebM blocks keep `id\nsettings\npayload` | -| `S_TEXT/ASS`, `S_TEXT/SSA` | `Ass` | script header | -| anything else | `Other(id)` | `Track::codec_private` | - -`Codec::name()` matches FFmpeg's `codec_name` for these. - -## Muxing (minimal) - -`MkvWriter::new(w: Write + Seek, Vec, MuxOptions)` → `write_frame(track, pts_ns, -keyframe, data, duration_ns)` → `finish()`. Writes the EBML header, Segment, SeekHead, Info (with -Duration), Tracks, Clusters of SimpleBlocks (new cluster at a video keyframe after -`cluster_duration_ns`), BlockGroups for explicit durations, optional audio lacing (Xiph, EBML, -fixed-size), and Cues (video keyframes, or the first frame of each track per cluster). No -CRC-32, Chapters, Tags, Attachments, or content encodings. - -## Tests - -- Unit: EBML VINTs (known/unknown sizes, IDs, signed lace deltas), typed values, CRC-32, all lacing - modes, codec mapping, a hand-built file (unknown-size Segment/Clusters, header stripping, - BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32 pass/fail, Cue-less - seek), and mux → demux round trips for every lacing mode. -- Oracle (`tests/oracle.rs`, skipped without ffmpeg/ffprobe; fixtures in `target/fixtures/matroska`): - H.264+AAC, VP9+Opus WebM, FLAC, Vorbis, SRT+ASS subtitles, WebVTT WebM, Cues at front, a - streamed (pipe) file without Cues, HEVC with HDR colour, ProRes+PCM, MJPEG+AC-3, AV1. Every packet - is compared with `ffprobe -show_packets` (stream, size, keyframe flag, pts, duration), plus codec - names, time bases, dimensions, audio params and duration; the sample index must equal the packet - stream. Seeks (indexed, Cues-only, and index-on-demand) must land on the latest keyframe ≤ target. - Our muxer's laced output (Xiph/EBML/fixed) is read back by ffprobe and compared the same way. - A damaged file must resync and deliver the packets after the damage; truncated files must not fail. - -## Limitations - -- Only the first Segment is read (no linked/chained segments, no ordered-chapter editions playback). -- zlib/bzlib/lzo compressed and encrypted tracks are detected but their frames are not decoded. -- BlockAdditions (e.g. VP9 alpha, WebVTT settings in Matroska) are skipped; `EncryptedBlock` ignored. -- `TrackTimestampScale` (deprecated) is ignored. -- Lace timestamps without any duration information repeat the block timestamp (FFmpeg derives them - from codec parsers). +A re-export of `craft_media::matroska`. Documentation: https://github.com/storytold/craft-libs/blob/main/crates/media/README.md diff --git a/crates/matroska/src/codec.rs b/crates/matroska/src/codec.rs deleted file mode 100644 index 8d83a974b..000000000 --- a/crates/matroska/src/codec.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Matroska CodecID → codec mapping (Matroska codec specifications, RFC 9559 §5.1.4.1.21 and the -//! codec mappings registry). - -/// Codec of a track, derived from `CodecID` and `CodecPrivate`. -/// -/// Configuration records are passed through as the raw bytes found in `CodecPrivate`, which for -/// the ISO codecs are exactly the ISOBMFF configuration boxes' payloads (avcC, hvcC, av1C, ASC…). -#[derive(Clone, Debug, PartialEq)] -pub enum Codec { - /// `V_MPEG4/ISO/AVC`; payload is an `AVCDecoderConfigurationRecord` (avcC). - Avc { avcc: Vec }, - /// `V_MPEGH/ISO/HEVC`; payload is an `HEVCDecoderConfigurationRecord` (hvcC). - Hevc { hvcc: Vec }, - /// `V_VP8`. - Vp8, - /// `V_VP9`; `CodecPrivate` (optional) holds VP9 codec feature metadata. - Vp9 { private: Vec }, - /// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C). - Av1 { av1c: Vec }, - /// `V_APV`; payload is an `APVDecoderConfigurationRecord` (apvC), if present. - Apv { apvc: Vec }, - /// `V_PRORES`; `fourcc` from `CodecPrivate` (e.g. `apcn`), if present. - ProRes { fourcc: Option<[u8; 4]> }, - /// `V_MJPEG`, or `V_MS/VFW/FOURCC` with an `MJPG` compression FourCC. - Mjpeg, - /// `V_MS/VFW/FOURCC` with any other FourCC; `bitmap_info_header` is the whole `CodecPrivate`. - VfwFourcc { fourcc: [u8; 4], bitmap_info_header: Vec }, - /// `A_AAC` (and the legacy `A_AAC/MPEG{2,4}/` IDs); `asc` is the - /// `AudioSpecificConfig` (synthesised for legacy IDs without `CodecPrivate`). - Aac { asc: Vec }, - /// `A_OPUS`; `head` is the `OpusHead` identification header. - Opus { head: Vec }, - /// `A_VORBIS`; the three Vorbis headers (identification, comment, setup), split from the - /// Xiph-laced `CodecPrivate`. - Vorbis { headers: Vec> }, - /// `A_FLAC`; `private` is `fLaC` + metadata blocks (STREAMINFO first). - Flac { private: Vec }, - /// `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE`. Bit depth comes from the Audio element. - Pcm { float: bool, big_endian: bool, bits: u16 }, - /// `A_AC3`. - Ac3, - /// `A_EAC3`. - Eac3, - /// `A_MPEG/L3`. - Mp3, - /// `A_MPEG/L2`. - Mp2, - /// `A_MPEG/L1`. - Mp1, - /// `S_TEXT/UTF8` (SubRip-style plain text). - SubRip, - /// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`. - WebVtt, - /// `S_TEXT/ASS` / `S_TEXT/SSA`; `header` is the script header from `CodecPrivate`. - Ass { header: Vec, ssa: bool }, - /// Any other CodecID (kept verbatim along with `Track::codec_private`). - Other(String), -} - -impl Codec { - /// Short codec name, matching FFmpeg's `codec_name` where one exists (useful for logs/UI). - pub fn name(&self) -> String { - match self { - Codec::Avc { .. } => "h264".into(), - Codec::Hevc { .. } => "hevc".into(), - Codec::Vp8 => "vp8".into(), - Codec::Vp9 { .. } => "vp9".into(), - Codec::Av1 { .. } => "av1".into(), - Codec::Apv { .. } => "apv".into(), - Codec::ProRes { .. } => "prores".into(), - Codec::Mjpeg => "mjpeg".into(), - Codec::VfwFourcc { fourcc, .. } => String::from_utf8_lossy(fourcc).trim().to_ascii_lowercase(), - Codec::Aac { .. } => "aac".into(), - Codec::Opus { .. } => "opus".into(), - Codec::Vorbis { .. } => "vorbis".into(), - Codec::Flac { .. } => "flac".into(), - Codec::Pcm { float, big_endian, bits } => { - let k = if *float { - 'f' - } else if *bits == 8 { - 'u' - } else { - 's' - }; - if *bits == 8 { format!("pcm_{k}8") } else { format!("pcm_{k}{bits}{}", if *big_endian { "be" } else { "le" }) } - } - Codec::Ac3 => "ac3".into(), - Codec::Eac3 => "eac3".into(), - Codec::Mp3 => "mp3".into(), - Codec::Mp2 => "mp2".into(), - Codec::Mp1 => "mp1".into(), - Codec::SubRip => "subrip".into(), - Codec::WebVtt => "webvtt".into(), - Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(), - Codec::Other(id) => id.clone(), - } - } -} - -const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; - -/// Build a 2-byte (or 5-byte with SBR extension) AudioSpecificConfig for legacy `A_AAC/...` IDs. -fn synth_asc(object_type: u8, rate: f64, out_rate: Option, channels: u64, sbr: bool) -> Vec { - let rate_index = |r: f64| AAC_RATES.iter().position(|&x| x as f64 == r).unwrap_or(4) as u8; - let ri = rate_index(rate); - let ch = channels.min(7) as u8; - let mut v = vec![(object_type << 3) | (ri >> 1), ((ri & 1) << 7) | (ch << 3)]; - if sbr { - // backward-compatible explicit signalling: sync 0x2b7, SBR object type 5, flag, ext rate index - let ext = rate_index(out_rate.unwrap_or(rate * 2.0)); - v.push(0x56); - v.push(0xE5); - v.push(0x80 | (ext << 3)); - } - v -} - -/// Split a Xiph-laced `CodecPrivate` (Vorbis/Theora headers). -fn split_xiph(p: &[u8]) -> Vec> { - let Some(&n) = p.first() else { return Vec::new() }; - let mut pos = 1; - let mut sizes = Vec::new(); - for _ in 0..n { - let mut s = 0usize; - loop { - let Some(&b) = p.get(pos) else { return Vec::new() }; - pos += 1; - s += b as usize; - if b != 255 { - break; - } - } - sizes.push(s); - } - let mut out = Vec::new(); - for s in sizes { - let Some(h) = p.get(pos..pos + s) else { return Vec::new() }; - out.push(h.to_vec()); - pos += s; - } - out.push(p[pos.min(p.len())..].to_vec()); - out -} - -/// Map a CodecID + CodecPrivate (+ audio params for legacy AAC / PCM) to a [`Codec`]. -pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInfo>) -> Codec { - let p = private.to_vec(); - match id { - "V_MPEG4/ISO/AVC" => Codec::Avc { avcc: p }, - "V_MPEGH/ISO/HEVC" => Codec::Hevc { hvcc: p }, - "V_VP8" => Codec::Vp8, - "V_VP9" => Codec::Vp9 { private: p }, - "V_AV1" => Codec::Av1 { av1c: p }, - "V_APV" => Codec::Apv { apvc: p }, - "V_PRORES" => Codec::ProRes { fourcc: private.get(..4).and_then(|f| f.try_into().ok()) }, - "V_MJPEG" => Codec::Mjpeg, - "V_MS/VFW/FOURCC" => { - let fourcc: [u8; 4] = private.get(16..20).and_then(|f| f.try_into().ok()).unwrap_or(*b" "); - match &fourcc { - b"MJPG" | b"mjpg" | b"AVRn" | b"AVDJ" => Codec::Mjpeg, - b"H264" | b"h264" | b"avc1" | b"X264" | b"x264" => Codec::Avc { avcc: Vec::new() }, - b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => Codec::ProRes { fourcc: Some(fourcc) }, - b"apv1" | b"APV1" => Codec::Apv { apvc: Vec::new() }, - _ => Codec::VfwFourcc { fourcc, bitmap_info_header: p }, - } - } - "A_AAC" => Codec::Aac { asc: p }, - "A_OPUS" => Codec::Opus { head: p }, - "A_VORBIS" => Codec::Vorbis { headers: split_xiph(private) }, - "A_FLAC" => Codec::Flac { private: p }, - "A_PCM/INT/LIT" | "A_PCM/INT/BIG" | "A_PCM/FLOAT/IEEE" => { - let bits = audio.and_then(|a| a.bit_depth).unwrap_or(if id == "A_PCM/FLOAT/IEEE" { 32 } else { 16 }) as u16; - Codec::Pcm { float: id == "A_PCM/FLOAT/IEEE", big_endian: id == "A_PCM/INT/BIG", bits } - } - "A_AC3" | "A_AC3/BSID9" | "A_AC3/BSID10" => Codec::Ac3, - "A_EAC3" => Codec::Eac3, - "A_MPEG/L3" => Codec::Mp3, - "A_MPEG/L2" => Codec::Mp2, - "A_MPEG/L1" => Codec::Mp1, - "S_TEXT/UTF8" => Codec::SubRip, - "S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt, - "S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false }, - "S_TEXT/SSA" | "S_SSA" => Codec::Ass { header: p, ssa: true }, - _ if id.starts_with("A_AAC/") => { - if !private.is_empty() { - return Codec::Aac { asc: p }; - } - let (rate, out_rate, ch) = audio.map_or((48000.0, None, 2), |a| (a.sampling_frequency, a.output_sampling_frequency, a.channels)); - let object_type = if id.ends_with("/MAIN") { - 1 - } else if id.ends_with("/SSR") { - 3 - } else if id.ends_with("/LTP") { - 4 - } else { - 2 - }; - let sbr = id.ends_with("/SBR"); - Codec::Aac { asc: synth_asc(object_type, rate, out_rate, ch, sbr) } - } - _ => Codec::Other(id.to_string()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mapping() { - assert_eq!(map_codec("V_MPEG4/ISO/AVC", &[1, 2], None), Codec::Avc { avcc: vec![1, 2] }); - assert_eq!(map_codec("V_APV", &[1, 2, 3], None), Codec::Apv { apvc: vec![1, 2, 3] }); - assert_eq!(map_codec("V_APV", &[], None).name(), "apv"); - assert_eq!(map_codec("V_PRORES", b"apcn", None), Codec::ProRes { fourcc: Some(*b"apcn") }); - let mut bih = vec![0u8; 40]; - bih[16..20].copy_from_slice(b"MJPG"); - assert_eq!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::Mjpeg); - bih[16..20].copy_from_slice(b"FFV1"); - assert!(matches!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::VfwFourcc { fourcc, .. } if &fourcc == b"FFV1")); - let a = crate::AudioInfo { sampling_frequency: 44100.0, channels: 2, bit_depth: Some(24), output_sampling_frequency: None }; - assert_eq!(map_codec("A_PCM/INT/LIT", &[], Some(&a)).name(), "pcm_s24le"); - assert_eq!(map_codec("A_PCM/FLOAT/IEEE", &[], Some(&a)), Codec::Pcm { float: true, big_endian: false, bits: 24 }); - // legacy AAC LC 44.1k stereo → 0x12 0x10 - assert_eq!(map_codec("A_AAC/MPEG4/LC", &[], Some(&a)), Codec::Aac { asc: vec![0x12, 0x10] }); - assert_eq!(map_codec("S_TEXT/UTF8", &[], None).name(), "subrip"); - assert_eq!(map_codec("X_WHATEVER", &[], None), Codec::Other("X_WHATEVER".into())); - } - - #[test] - fn xiph_split() { - let mut p = vec![2u8, 3, 2]; - p.extend_from_slice(&[1, 1, 1, 2, 2, 3, 3, 3, 3]); - let h = split_xiph(&p); - assert_eq!(h, vec![vec![1, 1, 1], vec![2, 2], vec![3, 3, 3, 3]]); - } -} diff --git a/crates/matroska/src/demux.rs b/crates/matroska/src/demux.rs deleted file mode 100644 index 4278a1b83..000000000 --- a/crates/matroska/src/demux.rs +++ /dev/null @@ -1,1219 +0,0 @@ -//! Matroska / WebM demuxer: EBML header, Segment walk, cluster scanning, packet reading, seeking. - -use std::collections::{HashSet, VecDeque}; - -use crate::ebml::{self, Children, Lacing, Size, parse_lacing, read_id, read_size}; -use crate::error::{Error, Result}; -use crate::ids::*; -use crate::meta::*; -use crate::source::{ByteSource, ReadSeekSource}; -use crate::track::{Sample, Track, TrackKind, gcd}; - -const WIN: usize = 64 * 1024; -/// Upper bound for level-1 metadata elements read into memory (Info, Tracks, Cues, Tags…). -const MAX_META: u64 = 256 << 20; - -/// Options for [`open_with`] / [`Demuxer::with_options`]. -#[derive(Clone, Debug)] -pub struct OpenOptions { - /// Scan every cluster at open to build per-track sample tables and keyframe indices - /// (reads only element and block headers, never frame payloads). Default `true`. - /// - /// With `false`, open stops at the first Cluster and uses the SeekHead to find Cues, Tags, - /// Chapters… placed after the clusters; [`Demuxer::seek`] then uses Cues (or builds the index - /// on first use if there are none). - pub index: bool, - /// Verify CRC-32 elements of level-1 elements (and clusters while indexing). Mismatches are - /// recorded in [`MkvFile::crc_errors`]; parsing continues. Default `false`. - pub verify_crc: bool, -} - -impl Default for OpenOptions { - fn default() -> Self { - Self { index: true, verify_crc: false } - } -} - -/// A parsed Matroska / WebM file (first Segment). -#[derive(Clone, Debug)] -pub struct MkvFile { - /// EBML `DocType`: `matroska` or `webm`. - pub doc_type: String, - pub doc_type_version: u64, - pub doc_type_read_version: u64, - /// Absolute offset of the Segment's data (base for SeekHead and Cues positions). - pub segment_data_start: u64, - /// Absolute end of the Segment (file end for unknown-size / live segments). - pub segment_end: u64, - /// Segment size field was "unknown" (live / streamed output). - pub live: bool, - pub info: SegmentInfo, - pub tracks: Vec, - pub seek_head: Vec, - /// Cue points, sorted by time. - pub cues: Vec, - /// Clusters in file order (complete only when [`MkvFile::indexed`]). - pub clusters: Vec, - pub chapters: Vec, - pub tags: Vec, - pub attachments: Vec, - /// Absolute offset of the first Cluster. - pub first_cluster: Option, - /// Sample tables have been built by scanning all clusters. - pub indexed: bool, - /// Number of CRC-32 elements verified (with `verify_crc`). - pub crc_checked: u32, - /// Absolute offsets of elements whose CRC-32 did not match. - pub crc_errors: Vec, - /// Non-fatal problems met while parsing (damage, resyncs, truncation). - pub warnings: Vec, -} - -/// One demuxed frame. -#[derive(Clone, Debug, PartialEq)] -pub struct Packet { - /// Index into [`MkvFile::tracks`]. - pub track: usize, - /// Matroska `TrackNumber`. - pub track_number: u64, - /// Presentation timestamp in timestamp ticks (the track timebase, [`Track::timebase`]). - pub pts: i64, - /// Presentation timestamp in nanoseconds. - pub pts_ns: i64, - /// Duration in ticks (0 if unknown). - pub duration: u64, - /// Duration in nanoseconds (0 if unknown). - pub duration_ns: u64, - pub keyframe: bool, - pub discardable: bool, - pub invisible: bool, - /// `DiscardPadding` (ns) from the enclosing BlockGroup. - pub discard_padding_ns: Option, - /// Absolute offset of the stored frame data. - pub offset: u64, - /// Index of the frame within a laced block (0 when not laced). Laced frames after the first - /// only have exact timestamps when the block or track carries a duration; otherwise they - /// repeat the block timestamp and `duration` is 0. - pub lace: u16, - /// Frame bytes with header stripping undone. Frames of tracks with other content encodings - /// (zlib, encryption) are returned as stored; see [`Track::frames_readable`]. - pub data: Vec, -} - -/// Result of [`Demuxer::seek`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SeekPoint { - pub track: usize, - /// Keyframe timestamp in ticks and ns. - pub pts: i64, - pub pts_ns: i64, - /// Sample index of the keyframe when the file is indexed. - pub sample: Option, - pub cluster_offset: u64, - pub block_offset: u64, -} - -/// A keyframe index entry ([`Demuxer::keyframe_index`]). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Keyframe { - pub sample: usize, - pub pts: i64, - pub pts_ns: i64, - pub block_offset: u64, -} - -// --------------------------------------------------------------------------------------------- -// I/O with a small read window -// --------------------------------------------------------------------------------------------- - -#[derive(Default, Clone, Debug)] -struct Cache { - buf: Vec, - start: u64, -} - -struct Io<'a, S: ByteSource + ?Sized> { - src: &'a S, - len: u64, - cache: &'a mut Cache, -} - -#[derive(Clone, Copy, Debug)] -struct Hdr { - id: u32, - pos: u64, - size: Size, - hlen: usize, -} - -impl Hdr { - fn data_start(&self) -> u64 { - self.pos + self.hlen as u64 - } - /// End offset, clamped to `limit`; unknown sizes run to `limit`. - fn end(&self, limit: u64) -> u64 { - match self.size { - Size::Known(n) => self.data_start().saturating_add(n).min(limit), - Size::Unknown => limit, - } - } - fn overruns(&self, limit: u64) -> bool { - matches!(self.size, Size::Known(n) if self.data_start().saturating_add(n) > limit) - } -} - -impl<'a, S: ByteSource + ?Sized> Io<'a, S> { - fn new(src: &'a S, cache: &'a mut Cache) -> Self { - let len = src.len(); - Self { src, len, cache } - } - - /// Up to `n` (≤ WIN) bytes at `pos` (fewer at EOF). - fn peek(&mut self, pos: u64, n: usize) -> Result<&[u8]> { - let n = n.min(WIN); - if pos >= self.len { - return Ok(&[]); - } - let want_end = (pos + n as u64).min(self.len); - let c = &*self.cache; - if !(pos >= c.start && want_end <= c.start + c.buf.len() as u64) { - let fill = ((self.len - pos) as usize).min(WIN); - let mut buf = std::mem::take(&mut self.cache.buf); - buf.resize(fill, 0); - self.src.read_at(pos, &mut buf)?; - self.cache.buf = buf; - self.cache.start = pos; - } - let o = (pos - self.cache.start) as usize; - Ok(&self.cache.buf[o..o + (want_end - pos) as usize]) - } - - /// Exactly `n` bytes at `pos`. - fn read_vec(&mut self, pos: u64, n: u64) -> Result> { - if pos.checked_add(n).is_none_or(|e| e > self.len) { - return Err(Error::Io(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file"))); - } - if n as usize <= WIN / 2 { - return Ok(self.peek(pos, n as usize)?.to_vec()); - } - let mut v = vec![0u8; n as usize]; - self.src.read_at(pos, &mut v)?; - Ok(v) - } - - /// Element header at `pos` (`None` if undecodable or past `limit`). - fn header(&mut self, pos: u64, limit: u64) -> Result> { - if pos >= limit { - return Ok(None); - } - let b = self.peek(pos, 12)?; - let Some((id, il)) = read_id(b) else { return Ok(None) }; - let Some((size, sl)) = read_size(&b[il..]) else { return Ok(None) }; - let hlen = il + sl; - if pos + hlen as u64 > limit { - return Ok(None); - } - Ok(Some(Hdr { id, pos, size, hlen })) - } - - /// Scan forward from `from` for a plausible level-1 element (Cluster preferred), up to `limit`. - fn resync(&mut self, from: u64, limit: u64) -> Result> { - let mut pos = from; - while pos + 4 <= limit { - let chunk = self.peek(pos, WIN)?.to_vec(); - if chunk.len() < 4 { - return Ok(None); - } - for i in 0..chunk.len() - 3 { - if chunk[i] & 0xF0 != 0x10 { - continue; - } - let id = u32::from_be_bytes([chunk[i], chunk[i + 1], chunk[i + 2], chunk[i + 3]]); - if !is_top_level(id) || id == EBML || id == SEGMENT { - continue; - } - let p = pos + i as u64; - if self.plausible(p, id, limit)? { - return Ok(Some(p)); - } - } - pos += (chunk.len() - 3) as u64; - } - Ok(None) - } - - fn plausible(&mut self, p: u64, id: u32, limit: u64) -> Result { - let Some(h) = self.header(p, limit)? else { return Ok(false) }; - if h.id != id || h.overruns(limit) { - return Ok(false); - } - if h.size == Size::Unknown && id != CLUSTER { - return Ok(false); - } - // first child must decode and fit; clusters must start with Timestamp (after CRC-32/Void) - let mut cp = h.data_start(); - let end = h.end(limit); - for _ in 0..3 { - let Some(c) = self.header(cp, end)? else { return Ok(false) }; - if c.overruns(end) { - return Ok(false); - } - if id != CLUSTER { - return Ok(true); - } - match c.id { - TIMESTAMP => return Ok(true), - CRC32 | VOID => cp = c.end(end), - _ => return Ok(false), - } - } - Ok(false) - } - - /// End of an unknown-size master element: the first child that is a top-level ID or undecodable. - fn unknown_end(&mut self, data_start: u64, limit: u64) -> Result { - let mut pos = data_start; - while pos < limit { - let Some(h) = self.header(pos, limit)? else { return Ok(pos) }; - if is_top_level(h.id) || h.size == Size::Unknown { - return Ok(pos); - } - pos = h.end(limit); - } - Ok(limit) - } -} - -// --------------------------------------------------------------------------------------------- -// Blocks -// --------------------------------------------------------------------------------------------- - -#[derive(Clone, Debug)] -struct BlockRef { - track: usize, - block_offset: u64, - /// Timestamp in ticks (cluster + relative). - ts: i64, - /// Absolute (offset, size) of each frame. - frames: Vec<(u64, u32)>, - block_duration: Option, - keyframe: bool, - discardable: bool, - invisible: bool, - discard_padding: Option, - /// (offset, size) of the `BlockAdditional` with `BlockAddID` 1 (WebM: the VP8/VP9 alpha - /// layer), kept for unlaced blocks only. - addition: Option<(u64, u32)>, -} - -#[derive(Clone, Copy, Debug)] -struct FrameRef { - track: usize, - offset: u64, - size: u32, - pts: i64, - pts_ns: i64, - duration: u64, - duration_ns: u64, - keyframe: bool, - discardable: bool, - invisible: bool, - discard_padding: Option, - block_offset: u64, - lace: u16, - addition: Option<(u64, u32)>, -} - -fn track_lookup(tracks: &[Track]) -> impl Fn(u64) -> Option + '_ { - move |n| tracks.iter().position(|t| t.number == n) -} - -/// Parse the Block/SimpleBlock header at `data_start` (`len` bytes). -/// Returns (track index, relative ts, flags, frames) or `None` for unknown tracks / damage. -fn parse_block( - io: &mut Io<'_, S>, - data_start: u64, - len: u64, - lookup: &dyn Fn(u64) -> Option, -) -> Result)>> { - let head = io.peek(data_start, 16.min(len as usize))?; - let Some((tn, tl, _)) = ebml::read_vint(head) else { return Ok(None) }; - if head.len() < tl + 3 { - return Ok(None); - } - let rel = i16::from_be_bytes([head[tl], head[tl + 1]]); - let flags = head[tl + 2]; - let Some(track) = lookup(tn) else { return Ok(None) }; - let hdr = (tl + 3) as u64; - let lacing = Lacing::from_flags(flags); - let frames = if lacing == Lacing::None { - vec![(data_start + hdr, (len - hdr) as u32)] - } else { - let body = io.read_vec(data_start + hdr, len - hdr)?; - match parse_lacing(lacing, &body) { - Ok(f) => f.into_iter().map(|(o, l)| (data_start + hdr + o as u64, l as u32)).collect(), - Err(_) => return Ok(None), - } - }; - Ok(Some((track, rel, flags, frames))) -} - -/// The `BlockAdditional` with `BlockAddID` 1 (the default ID) in a `BlockAdditions` element: -/// (offset, size) of its data. Other IDs, empty additions and damaged children are skipped. -fn block_addition(io: &mut Io<'_, S>, start: u64, end: u64) -> Result> { - let mut pos = start; - while pos < end { - let Some(more) = io.header(pos, end)? else { break }; - let me = more.end(end); - if more.id == BLOCK_MORE { - let (mut id, mut data) = (1u64, None); - let mut p = more.data_start(); - while p < me { - let Some(c) = io.header(p, me)? else { break }; - let ce = c.end(me); - let len = ce.saturating_sub(c.data_start()); - match c.id { - BLOCK_ADD_ID => id = ebml::uint(io.peek(c.data_start(), len.min(8) as usize)?), - BLOCK_ADDITIONAL => data = u32::try_from(len).ok().filter(|&n| n > 0).map(|n| (c.data_start(), n)), - _ => {} - } - p = ce; - } - if id == 1 && data.is_some() { - return Ok(data); - } - } - pos = me; - } - Ok(None) -} - -/// Parse one SimpleBlock or BlockGroup element. -fn parse_block_elem( - io: &mut Io<'_, S>, - h: &Hdr, - end: u64, - cluster_ts: i64, - lookup: &dyn Fn(u64) -> Option, -) -> Result> { - let ds = h.data_start(); - match h.id { - SIMPLE_BLOCK => { - let Some((track, rel, flags, frames)) = parse_block(io, ds, end - ds, lookup)? else { return Ok(None) }; - Ok(Some(BlockRef { - track, - block_offset: h.pos, - ts: cluster_ts + rel as i64, - frames, - block_duration: None, - keyframe: flags & 0x80 != 0, - discardable: flags & 0x01 != 0, - invisible: flags & 0x08 != 0, - discard_padding: None, - addition: None, - })) - } - BLOCK_GROUP => { - let mut pos = ds; - let mut block = None; - let (mut dur, mut has_ref, mut pad, mut addition) = (None, false, None, None); - while pos < end { - let Some(c) = io.header(pos, end)? else { break }; - let ce = c.end(end); - match c.id { - BLOCK => block = parse_block(io, c.data_start(), ce - c.data_start(), lookup)?, - BLOCK_ADDITIONS => addition = block_addition(io, c.data_start(), ce)?, - BLOCK_DURATION => dur = Some(ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)), - REFERENCE_BLOCK => has_ref = true, - DISCARD_PADDING => pad = Some(ebml::int(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)), - _ => {} - } - pos = ce; - } - let Some((track, rel, flags, frames)) = block else { return Ok(None) }; - // with lacing, an addition belongs to the block, not to one of its frames - let addition = addition.filter(|_| frames.len() == 1); - Ok(Some(BlockRef { - track, - block_offset: h.pos, - ts: cluster_ts + rel as i64, - frames, - block_duration: dur, - keyframe: !has_ref, - discardable: false, - invisible: flags & 0x08 != 0, - discard_padding: pad, - addition, - })) - } - _ => Ok(None), - } -} - -/// Expand a block into frames with per-lace timestamps. -fn expand(b: &BlockRef, t: &Track, scale: u64, skip: u16, out: &mut impl Extend) { - let n = b.frames.len() as u64; - // total block duration in ns - let block_ns: Option = match (b.block_duration, t.default_duration_ns) { - (Some(d), _) => Some(d as u128 * scale as u128), - (None, Some(dd)) => Some(dd as u128 * n as u128), - _ => None, - }; - let scale_i = scale as i128; - // round half away from zero - let to_ticks = |ns: i128| -> i64 { (if ns >= 0 { (ns + scale_i / 2) / scale_i } else { (ns - scale_i / 2) / scale_i }) as i64 }; - // CodecDelay is subtracted from every timestamp (RFC 9559 §5.1.4.1.25); in ticks it is rounded - // once, so tick timestamps stay on the block grid. - let delay_ns = t.codec_delay_ns as i128; - let delay_ticks = to_ticks(delay_ns); - let base_ns = b.ts as i128 * scale_i; - out.extend(b.frames.iter().enumerate().skip(skip as usize).map(|(i, &(offset, size))| { - let (start, end) = match block_ns { - Some(total) => ((total * i as u128 / n as u128) as i128, Some((total * (i as u128 + 1) / n as u128) as i128)), - None => (0, None), - }; - let pts_ns = base_ns + start - delay_ns; - let pts = to_ticks(base_ns + start) - delay_ticks; - let duration = end.map_or(0, |e| (to_ticks(e) - to_ticks(start)).max(0) as u64); - FrameRef { - track: b.track, - offset, - size, - pts, - pts_ns: pts_ns as i64, - duration, - duration_ns: end.map_or(0, |e| (e - start).max(0) as u64), - keyframe: b.keyframe, - discardable: b.discardable, - invisible: b.invisible, - discard_padding: b.discard_padding, - block_offset: b.block_offset, - lace: i as u16, - addition: b.addition, - } - })); -} - -/// Scan a whole cluster (headers only), returning its info and blocks. -fn scan_cluster( - io: &mut Io<'_, S>, - h: &Hdr, - limit: u64, - tracks: &[Track], - warnings: &mut Vec, -) -> Result<(ClusterInfo, Vec)> { - let lookup = track_lookup(tracks); - let ds = h.data_start(); - let known_end = match h.size { - Size::Known(_) => Some(h.end(limit)), - Size::Unknown => None, - }; - let lim = known_end.unwrap_or(limit); - let mut ts: i64 = 0; - let mut blocks = Vec::new(); - let mut pos = ds; - let end = loop { - if pos >= lim { - break lim; - } - let Some(c) = io.header(pos, lim)? else { - warnings.push(format!("damaged cluster at {}: undecodable element at {pos}", h.pos)); - break io.resync(pos + 1, limit)?.unwrap_or(limit); - }; - if known_end.is_none() && is_top_level(c.id) { - break pos; - } - if c.size == Size::Unknown || c.overruns(lim) { - warnings.push(format!("damaged cluster at {}: element 0x{:X} at {pos} overruns", h.pos, c.id)); - if c.overruns(lim) && matches!(c.id, SIMPLE_BLOCK | BLOCK_GROUP) && io.len <= c.data_start() + c.size.known().unwrap_or(0) { - break lim; // truncated file: drop the partial block - } - break io.resync(pos + 1, limit)?.unwrap_or(limit); - } - let ce = c.end(lim); - match c.id { - TIMESTAMP => ts = ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?) as i64, - SIMPLE_BLOCK | BLOCK_GROUP => { - if let Some(b) = parse_block_elem(io, &c, ce, ts, &lookup)? { - blocks.push(b); - } - } - _ => {} - } - pos = ce; - }; - Ok((ClusterInfo { offset: h.pos, data_start: ds, end, timestamp: ts.max(0) as u64 }, blocks)) -} - -// --------------------------------------------------------------------------------------------- -// Open -// --------------------------------------------------------------------------------------------- - -/// Open a Matroska/WebM file with default options (full sample index). -pub fn open(src: S) -> Result { - open_with(src, &OpenOptions::default()) -} - -/// Open with explicit options. -pub fn open_with(src: S, opts: &OpenOptions) -> Result { - let mut cache = Cache::default(); - open_impl(&src, opts, &mut cache) -} - -fn open_impl(src: &S, opts: &OpenOptions, cache: &mut Cache) -> Result { - let mut io = Io::new(src, cache); - let len = io.len; - // EBML header (tolerate leading junk in the first 64 KiB) - let start = { - let b = io.peek(0, WIN)?; - let magic = [0x1A, 0x45, 0xDF, 0xA3]; - b.windows(4).position(|w| w == magic).ok_or_else(|| Error::NotMatroska("no EBML header".into()))? as u64 - }; - let eh = io.header(start, len)?.ok_or_else(|| Error::NotMatroska("bad EBML header".into()))?; - let hdr_end = eh.end(len); - let hdr = io.read_vec(eh.data_start(), hdr_end - eh.data_start())?; - let (mut doc_type, mut dtv, mut dtrv) = ("matroska".to_string(), 1, 1); - for e in Children::new(&hdr) { - match e.id { - DOC_TYPE => doc_type = ebml::string(e.data), - DOC_TYPE_VERSION => dtv = ebml::uint(e.data), - DOC_TYPE_READ_VERSION => dtrv = ebml::uint(e.data), - EBML_MAX_ID_LENGTH if ebml::uint(e.data) > 4 => return Err(Error::Unsupported("EBMLMaxIDLength > 4".into())), - EBML_MAX_SIZE_LENGTH if ebml::uint(e.data) > 8 => return Err(Error::Unsupported("EBMLMaxSizeLength > 8".into())), - _ => {} - } - } - if doc_type != "matroska" && doc_type != "webm" { - return Err(Error::NotMatroska(format!("DocType {doc_type:?}"))); - } - // Segment - let mut pos = hdr_end; - let seg = loop { - let h = io.header(pos, len)?.ok_or_else(|| Error::Invalid("no Segment".into()))?; - if h.id == SEGMENT { - break h; - } - if h.size == Size::Unknown { - return Err(Error::Invalid("unknown-size element before Segment".into())); - } - pos = h.end(len); - }; - let mut file = MkvFile { - doc_type, - doc_type_version: dtv, - doc_type_read_version: dtrv, - segment_data_start: seg.data_start(), - segment_end: seg.end(len), - live: seg.size == Size::Unknown, - info: SegmentInfo::default(), - tracks: Vec::new(), - seek_head: Vec::new(), - cues: Vec::new(), - clusters: Vec::new(), - chapters: Vec::new(), - tags: Vec::new(), - attachments: Vec::new(), - first_cluster: None, - indexed: false, - crc_checked: 0, - crc_errors: Vec::new(), - warnings: Vec::new(), - }; - if seg.overruns(len) { - file.warnings.push("Segment extends past end of file (truncated)".into()); - } - let mut w = Walker { opts, parsed: HashSet::new(), blocks_seen: false }; - w.walk(&mut io, &mut file)?; - finish_tracks(&mut file); - Ok(file) -} - -struct Walker<'o> { - opts: &'o OpenOptions, - parsed: HashSet, - blocks_seen: bool, -} - -impl Walker<'_> { - fn walk(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile) -> Result<()> { - let seg_end = file.segment_end; - let mut pos = file.segment_data_start; - while pos < seg_end { - let Some(h) = io.header(pos, seg_end)? else { - match io.resync(pos + 1, seg_end)? { - Some(p) => { - file.warnings.push(format!("undecodable element at {pos}; resynced at {p}")); - pos = p; - continue; - } - None => break, - } - }; - match h.id { - CLUSTER => { - if file.first_cluster.is_none() { - file.first_cluster = Some(h.pos); - // make sure Info/Tracks are known before interpreting blocks - self.follow_seek_head(io, file, &[INFO, TRACKS])?; - if !self.opts.index { - break; - } - } - let (ci, blocks) = scan_cluster(io, &h, seg_end, &file.tracks, &mut file.warnings)?; - if self.opts.verify_crc && h.size != Size::Unknown { - let data = io.read_vec(h.data_start(), ci.end - h.data_start())?; - self.check_crc(file, h.pos, &data); - } - add_blocks(file, file.clusters.len() as u32, &blocks); - self.blocks_seen = true; - file.clusters.push(ci); - pos = ci.end.max(h.data_start()); - } - SEEK_HEAD | INFO | TRACKS | CUES | CHAPTERS | TAGS | ATTACHMENTS => { - pos = self.level1(io, file, &h, seg_end)?; - } - EBML | SEGMENT => break, // chained segment: only the first one is read - _ => { - if h.size == Size::Unknown { - pos = io.unknown_end(h.data_start(), seg_end)?; - } else if h.overruns(seg_end) && h.id != VOID { - match io.resync(pos + 1, seg_end)? { - Some(p) => { - file.warnings.push(format!("oversized element 0x{:X} at {pos}; resynced at {p}", h.id)); - pos = p; - } - None => break, - } - } else { - pos = h.end(seg_end); - } - } - } - } - self.follow_seek_head(io, file, &[INFO, TRACKS, CUES, CHAPTERS, TAGS, ATTACHMENTS, SEEK_HEAD])?; - // follow a second-level SeekHead reached through the first one - self.follow_seek_head(io, file, &[CUES, CHAPTERS, TAGS, ATTACHMENTS])?; - file.cues.sort_by_key(|c| c.time); - file.indexed = self.opts.index; - Ok(()) - } - - fn follow_seek_head(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, ids: &[u32]) -> Result<()> { - let entries = file.seek_head.clone(); - for e in entries { - if !ids.contains(&e.id) { - continue; - } - let Some(abs) = file.segment_data_start.checked_add(e.position) else { continue }; - if self.parsed.contains(&abs) || abs >= file.segment_end { - continue; - } - match io.header(abs, file.segment_end)? { - Some(h) if h.id == e.id => { - self.level1(io, file, &h, file.segment_end)?; - } - _ => file.warnings.push(format!("SeekHead entry 0x{:X} -> {abs} does not point at that element", e.id)), - } - } - Ok(()) - } - - fn check_crc(&self, file: &mut MkvFile, pos: u64, data: &[u8]) { - if let Some(ok) = ebml::verify_crc(data) { - file.crc_checked += 1; - if !ok { - file.crc_errors.push(pos); - file.warnings.push(format!("CRC-32 mismatch in element at {pos}")); - } - } - } - - /// Parse a level-1 metadata element; returns its end. - fn level1(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, h: &Hdr, limit: u64) -> Result { - let end = if h.size == Size::Unknown { io.unknown_end(h.data_start(), limit)? } else { h.end(limit) }; - if h.overruns(limit) { - file.warnings.push(format!("element 0x{:X} at {} truncated", h.id, h.pos)); - } - if !self.parsed.insert(h.pos) { - return Ok(end); - } - if h.id == ATTACHMENTS { - self.attachments(io, file, h.data_start(), end)?; - return Ok(end); - } - let n = end - h.data_start(); - if n > MAX_META { - file.warnings.push(format!("element 0x{:X} at {} too large ({n} bytes); skipped", h.id, h.pos)); - return Ok(end); - } - let data = io.read_vec(h.data_start(), n)?; - if self.opts.verify_crc { - self.check_crc(file, h.pos, &data); - } - match h.id { - SEEK_HEAD => file.seek_head.extend(parse_seekhead(&data)), - INFO => file.info = parse_info(&data), - TRACKS if file.tracks.is_empty() => { - file.tracks = parse_tracks(&data, file.info.timestamp_scale); - } - CUES => parse_cues(&data, &mut file.cues), - CHAPTERS => parse_chapters(&data, &mut file.chapters), - TAGS => parse_tags(&data, &mut file.tags), - _ => {} - } - Ok(end) - } - - fn attachments(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, start: u64, end: u64) -> Result<()> { - let mut pos = start; - while pos < end { - let Some(h) = io.header(pos, end)? else { break }; - let he = h.end(end); - if h.id == ATTACHED_FILE { - let mut a = Attachment::default(); - let mut p = h.data_start(); - while p < he { - let Some(c) = io.header(p, he)? else { break }; - let ce = c.end(he); - let n = ce - c.data_start(); - if c.id == FILE_DATA { - a.data_offset = c.data_start(); - a.data_size = n; - } else if n <= 4096 { - let d = io.peek(c.data_start(), n as usize)?; - match c.id { - FILE_NAME => a.name = ebml::string(d), - FILE_MEDIA_TYPE => a.media_type = ebml::string(d), - FILE_DESCRIPTION => a.description = Some(ebml::string(d)), - FILE_UID => a.uid = ebml::uint(d), - _ => {} - } - } - p = ce; - } - file.attachments.push(a); - } - pos = he; - } - Ok(()) - } -} - -fn add_blocks(file: &mut MkvFile, cluster: u32, blocks: &[BlockRef]) { - let scale = file.info.timestamp_scale; - let mut frames: Vec = Vec::new(); - for b in blocks { - frames.clear(); - let t = &file.tracks[b.track]; - expand(b, t, scale, 0, &mut frames); - let t = &mut file.tracks[b.track]; - for f in &frames { - if f.keyframe { - t.sync.push(t.samples.len() as u32); - } - t.samples.push(Sample { - offset: f.offset, - size: f.size, - pts: f.pts, - duration: f.duration, - keyframe: f.keyframe, - cluster, - block_offset: f.block_offset, - lace: f.lace, - addition: f.addition, - }); - } - } -} - -fn finish_tracks(file: &mut MkvFile) { - let scale = file.info.timestamp_scale; - let g = gcd(scale, 1_000_000_000); - for t in &mut file.tracks { - t.timebase = (scale / g, 1_000_000_000 / g); - } -} - -// --------------------------------------------------------------------------------------------- -// MkvFile helpers -// --------------------------------------------------------------------------------------------- - -impl MkvFile { - /// True for WebM (`DocType` = `webm`). - pub fn is_webm(&self) -> bool { - self.doc_type == "webm" - } - - /// Segment duration in ns: `Info/Duration` if present, else the end of the last indexed sample. - pub fn duration_ns(&self) -> Option { - if let Some(d) = self.info.duration { - return Some((d * self.info.timestamp_scale as f64).round() as u64); - } - let end = self.tracks.iter().flat_map(|t| t.samples.iter().map(|s| s.pts + s.duration as i64)).max()?; - Some((end.max(0) as u64).saturating_mul(self.info.timestamp_scale)) - } - - /// Index of the track with this `TrackNumber`. - pub fn track_by_number(&self, number: u64) -> Option { - self.tracks.iter().position(|t| t.number == number) - } - - /// Index of the first track of the given kind (preferring the default flag). - pub fn track_of_kind(&self, kind: TrackKind) -> Option { - let mut it = self.tracks.iter().enumerate().filter(|(_, t)| t.kind == kind); - let first = it.clone().next().map(|x| x.0); - it.find(|(_, t)| t.default).map(|x| x.0).or(first) - } - - /// Read sample `index` of `track` (header stripping undone). - pub fn read_sample(&self, src: &S, track: usize, index: usize) -> Result> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - if !t.frames_readable() { - return Err(Error::Unsupported(format!("content encoding of track {} (compression/encryption)", t.number))); - } - let mut data = t.frame_prefix(s.size as u64); - let at = data.len(); - data.resize(at + s.size as usize, 0); - src.read_at(s.offset, &mut data[at..])?; - Ok(data) - } - - /// The `BlockAdditional` (ID 1) of sample `index` of `track`, `None` when the block has none - /// (see [`Sample::addition`]). Stored as is: header stripping applies to frames only. - pub fn read_sample_addition(&self, src: &S, track: usize, index: usize) -> Result>> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - let Some((offset, size)) = s.addition else { return Ok(None) }; - if offset.checked_add(size as u64).is_none_or(|end| end > src.len()) { - return Err(Error::Invalid("block addition past end of file".into())); - } - let mut data = vec![0; size as usize]; - src.read_at(offset, &mut data)?; - Ok(Some(data)) - } - - /// Keyframe sample of `track` with the greatest pts ≤ `time_ns` (indexed files only). - pub fn keyframe_before(&self, track: usize, time_ns: i64) -> Option { - let t = self.tracks.get(track)?; - t.keyframe_before_pts(t.ns_to_ticks(time_ns)) - } -} - -// --------------------------------------------------------------------------------------------- -// Demuxer (streaming packets + seeking) -// --------------------------------------------------------------------------------------------- - -#[derive(Clone, Copy, Debug)] -struct InCluster { - ts: i64, - end: Option, -} - -/// Packet reader over a [`ByteSource`]: iterates frames in file order and seeks to keyframes. -pub struct Demuxer { - src: S, - file: MkvFile, - opts: OpenOptions, - cache: Cache, - pos: u64, - cluster: Option, - pending: VecDeque, - skip_laces: Option<(u64, u16)>, - done: bool, -} - -impl Demuxer> { - /// Open from any `Read + Seek` stream. - pub fn from_reader(r: R) -> Result { - Self::new(ReadSeekSource::new(r)?) - } -} - -impl<'a> Demuxer<&'a [u8]> { - /// Open from an in-memory byte slice. - pub fn from_slice(data: &'a [u8]) -> Result { - Self::new(data) - } -} - -impl Demuxer { - /// Open with default options (full index). - pub fn new(src: S) -> Result { - Self::with_options(src, OpenOptions::default()) - } - - pub fn with_options(src: S, opts: OpenOptions) -> Result { - let mut cache = Cache::default(); - let file = open_impl(&src, &opts, &mut cache)?; - let pos = file.first_cluster.unwrap_or(file.segment_end); - Ok(Self { src, file, opts, cache, pos, cluster: None, pending: VecDeque::new(), skip_laces: None, done: false }) - } - - pub fn file(&self) -> &MkvFile { - &self.file - } - pub fn tracks(&self) -> &[Track] { - &self.file.tracks - } - pub fn source(&self) -> &S { - &self.src - } - pub fn into_parts(self) -> (S, MkvFile) { - (self.src, self.file) - } - /// Segment duration in ns. - pub fn duration_ns(&self) -> Option { - self.file.duration_ns() - } - - /// Scan all clusters and build sample tables (no-op if already indexed). - pub fn build_index(&mut self) -> Result<()> { - if self.file.indexed { - return Ok(()); - } - let opts = OpenOptions { index: true, verify_crc: self.opts.verify_crc }; - self.file = open_impl(&self.src, &opts, &mut self.cache)?; - Ok(()) - } - - /// Keyframes of `track` (builds the index if needed). - pub fn keyframe_index(&mut self, track: usize) -> Result> { - self.build_index()?; - let t = self.file.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - Ok(t.keyframes() - .iter() - .map(|&k| { - let s = &t.samples[k as usize]; - Keyframe { sample: k as usize, pts: s.pts, pts_ns: t.ticks_to_ns(s.pts), block_offset: s.block_offset } - }) - .collect()) - } - - /// Rewind to the first cluster. - pub fn rewind(&mut self) { - self.pos = self.file.first_cluster.unwrap_or(self.file.segment_end); - self.cluster = None; - self.pending.clear(); - self.skip_laces = None; - self.done = false; - } - - /// Read sample `index` of `track` (requires an index). - pub fn read_sample(&self, track: usize, index: usize) -> Result> { - self.file.read_sample(&self.src, track, index) - } - - /// Position the packet stream at the keyframe of `track` with the greatest pts ≤ `time_ns` - /// (or the track's first keyframe if none precedes it). The next [`Demuxer::next_packet`] - /// call returns packets starting at that keyframe's block (all tracks). - /// - /// Uses the sample index when present, else Cues (refined by scanning the cued cluster), else - /// builds the index first. - pub fn seek(&mut self, track: usize, time_ns: i64) -> Result { - let t = self.file.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let ticks = t.ns_to_ticks(time_ns); - if !self.file.indexed { - let tn = t.number; - let cue = self - .file - .cues - .iter() - .filter(|c| c.time as i64 <= ticks) - .filter_map(|c| c.positions.iter().find(|p| p.track == tn).map(|p| (c.time, *p))) - .next_back() - .or_else(|| self.file.cues.iter().find_map(|c| c.positions.iter().find(|p| p.track == tn).map(|p| (c.time, *p)))); - if let Some((_, cp)) = cue - && let Some(sp) = self.seek_via_cue(track, ticks, cp.cluster_position)? - { - return Ok(sp); - } - self.build_index()?; - } - let t = &self.file.tracks[track]; - let k = t.keyframe_before_pts(ticks).ok_or_else(|| Error::Invalid(format!("track {} has no keyframes", t.number)))?; - let s = t.samples[k]; - let c = self.file.clusters[s.cluster as usize]; - let sp = SeekPoint { track, pts: s.pts, pts_ns: t.ticks_to_ns(s.pts), sample: Some(k), cluster_offset: c.offset, block_offset: s.block_offset }; - self.position_at(c, s.block_offset, s.lace); - Ok(sp) - } - - fn position_at(&mut self, c: ClusterInfo, block_offset: u64, lace: u16) { - self.pending.clear(); - self.done = false; - self.pos = block_offset; - let known = c.end; - self.cluster = Some(InCluster { ts: c.timestamp as i64, end: Some(known) }); - self.skip_laces = (lace > 0).then_some((block_offset, lace)); - } - - fn seek_via_cue(&mut self, track: usize, ticks: i64, cluster_position: u64) -> Result> { - let abs = self.file.segment_data_start + cluster_position; - let mut io = Io::new(&self.src, &mut self.cache); - let Some(h) = io.header(abs, self.file.segment_end)? else { return Ok(None) }; - if h.id != CLUSTER { - self.file.warnings.push(format!("cue points at {abs}, which is not a Cluster")); - return Ok(None); - } - let mut warnings = Vec::new(); - let (ci, blocks) = scan_cluster(&mut io, &h, self.file.segment_end, &self.file.tracks, &mut warnings)?; - let t = &self.file.tracks[track]; - let mut frames = Vec::new(); - for b in blocks.iter().filter(|b| b.track == track && b.keyframe) { - expand(b, t, self.file.info.timestamp_scale, 0, &mut frames); - } - let best = frames.iter().filter(|f| f.pts <= ticks).max_by_key(|f| f.pts).or_else(|| frames.iter().min_by_key(|f| f.pts)).copied(); - let Some(f) = best else { return Ok(None) }; - let sp = SeekPoint { track, pts: f.pts, pts_ns: f.pts_ns, sample: None, cluster_offset: ci.offset, block_offset: f.block_offset }; - self.position_at(ci, f.block_offset, f.lace); - Ok(Some(sp)) - } - - /// Next frame in file order, or `None` at the end of the Segment. - pub fn next_packet(&mut self) -> Result> { - loop { - if let Some(f) = self.pending.pop_front() { - return self.materialize(f).map(Some); - } - if self.done || !self.fill()? { - self.done = true; - return Ok(None); - } - } - } - - fn materialize(&mut self, f: FrameRef) -> Result { - let t = &self.file.tracks[f.track]; - let mut data = if t.frames_readable() { t.frame_prefix(f.size as u64) } else { Vec::new() }; - let at = data.len(); - data.resize(at + f.size as usize, 0); - let mut io = Io::new(&self.src, &mut self.cache); - if (f.size as usize) <= WIN / 2 { - let b = io.peek(f.offset, f.size as usize)?; - if b.len() != f.size as usize { - return Err(Error::Invalid("frame data past end of file".into())); - } - data[at..].copy_from_slice(b); - } else { - self.src.read_at(f.offset, &mut data[at..])?; - } - Ok(Packet { - track: f.track, - track_number: t.number, - pts: f.pts, - pts_ns: f.pts_ns, - duration: f.duration, - duration_ns: f.duration_ns, - keyframe: f.keyframe, - discardable: f.discardable, - invisible: f.invisible, - discard_padding_ns: f.discard_padding, - offset: f.offset, - lace: f.lace, - data, - }) - } - - /// Advance over elements until at least one frame is pending. Returns false at the end. - fn fill(&mut self) -> Result { - let seg_end = self.file.segment_end; - let scale = self.file.info.timestamp_scale; - let mut io = Io::new(&self.src, &mut self.cache); - let lookup = track_lookup(&self.file.tracks); - loop { - if let Some(c) = self.cluster { - let lim = c.end.unwrap_or(seg_end); - if self.pos >= lim { - self.cluster = None; - continue; - } - let Some(h) = io.header(self.pos, lim)? else { - self.cluster = None; - match io.resync(self.pos + 1, seg_end)? { - Some(p) => { - self.pos = p; - continue; - } - None => return Ok(false), - } - }; - if c.end.is_none() && is_top_level(h.id) { - self.cluster = None; - continue; - } - if h.size == Size::Unknown || h.overruns(lim) { - self.cluster = None; - match io.resync(self.pos + 1, seg_end)? { - Some(p) => { - self.pos = p; - continue; - } - None => return Ok(false), - } - } - let he = h.end(lim); - self.pos = he; - match h.id { - TIMESTAMP => { - let v = ebml::uint(io.peek(h.data_start(), (he - h.data_start()) as usize)?) as i64; - self.cluster = Some(InCluster { ts: v, end: c.end }); - } - SIMPLE_BLOCK | BLOCK_GROUP => { - if let Some(b) = parse_block_elem(&mut io, &h, he, c.ts, &lookup)? { - let skip = match self.skip_laces.take() { - Some((off, n)) if off == h.pos => n, - _ => 0, - }; - expand(&b, &self.file.tracks[b.track], scale, skip, &mut self.pending); - if !self.pending.is_empty() { - return Ok(true); - } - } - } - _ => {} - } - continue; - } - if self.pos >= seg_end { - return Ok(false); - } - let Some(h) = io.header(self.pos, seg_end)? else { - match io.resync(self.pos + 1, seg_end)? { - Some(p) => { - self.pos = p; - continue; - } - None => return Ok(false), - } - }; - match h.id { - CLUSTER => { - self.cluster = Some(InCluster { ts: 0, end: h.size.known().map(|_| h.end(seg_end)) }); - self.pos = h.data_start(); - } - EBML | SEGMENT => return Ok(false), - _ => { - self.pos = if h.size == Size::Unknown { io.unknown_end(h.data_start(), seg_end)? } else { h.end(seg_end) }; - if self.pos <= h.pos { - return Ok(false); - } - } - } - } - } -} - -impl Iterator for Demuxer { - type Item = Result; - fn next(&mut self) -> Option> { - match self.next_packet() { - Ok(Some(p)) => Some(Ok(p)), - Ok(None) => None, - Err(e) => { - self.done = true; - Some(Err(e)) - } - } - } -} diff --git a/crates/matroska/src/ebml.rs b/crates/matroska/src/ebml.rs deleted file mode 100644 index 6c2db76de..000000000 --- a/crates/matroska/src/ebml.rs +++ /dev/null @@ -1,476 +0,0 @@ -//! EBML primitives (RFC 8794): variable-length integers, element headers, typed values, CRC-32, -//! and Matroska block lacing (RFC 9559 §10.3). - -use crate::error::{Result, invalid}; - -/// Length in bytes of a VINT whose first byte is `b` (1..=8), or `None` for a zero first byte. -#[inline] -pub fn vint_len(b: u8) -> Option { - if b == 0 { None } else { Some(b.leading_zeros() as usize + 1) } -} - -/// Element data size: known, or "unknown" (all value bits set). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Size { - Known(u64), - Unknown, -} - -impl Size { - pub fn known(self) -> Option { - match self { - Size::Known(n) => Some(n), - Size::Unknown => None, - } - } -} - -/// Decode an unsigned VINT (marker bit stripped). Returns `(value, length, all_ones)`. -pub fn read_vint(data: &[u8]) -> Option<(u64, usize, bool)> { - let first = *data.first()?; - let len = vint_len(first)?; - if data.len() < len { - return None; - } - let mask = if len == 8 { 0 } else { 0xFFu8 >> len }; - let mut v = (first & mask) as u64; - for &b in &data[1..len] { - v = (v << 8) | b as u64; - } - let all_ones = v == (1u64 << (7 * len)) - 1; - Some((v, len, all_ones)) -} - -/// Decode an element data size. All-ones is the reserved "unknown size". -pub fn read_size(data: &[u8]) -> Option<(Size, usize)> { - let (v, len, ones) = read_vint(data)?; - Some((if ones { Size::Unknown } else { Size::Known(v) }, len)) -} - -/// Decode an element ID (marker bits kept, 1..=4 bytes). Rejects all-ones value bits (reserved). -/// All-zero value bits are accepted, because Matroska's `ChapterDisplay` uses ID `0x80`. -pub fn read_id(data: &[u8]) -> Option<(u32, usize)> { - let first = *data.first()?; - let len = vint_len(first)?; - if len > 4 || data.len() < len { - return None; - } - let (v, _, ones) = read_vint(&data[..len])?; - if ones || (v == 0 && len > 1) { - return None; - } - let mut id = 0u32; - for &b in &data[..len] { - id = (id << 8) | b as u32; - } - Some((id, len)) -} - -/// Decode a signed VINT as used by EBML lacing (value − (2^(7·len−1) − 1)). -pub fn read_svint(data: &[u8]) -> Option<(i64, usize)> { - let (v, len, _) = read_vint(data)?; - let bias = (1i64 << (7 * len - 1)) - 1; - Some((v as i64 - bias, len)) -} - -/// Encode an element ID (already carrying its marker bits). -pub fn write_id(out: &mut Vec, id: u32) { - let n = if id >= 0x100_0000 { - 4 - } else if id >= 0x1_0000 { - 3 - } else if id >= 0x100 { - 2 - } else { - 1 - }; - out.extend_from_slice(&id.to_be_bytes()[4 - n..]); -} - -/// Encode a data size as a VINT using the shortest length (or `min_len` if larger). -pub fn write_size(out: &mut Vec, size: u64, min_len: usize) { - let mut len = 1; - while len < 8 && size >= (1u64 << (7 * len)) - 1 { - len += 1; - } - let len = len.max(min_len).min(8); - let v = size | (1u64 << (7 * len)); - out.extend_from_slice(&v.to_be_bytes()[8 - len..]); -} - -/// Encode the "unknown size" marker of the given length. -pub fn write_unknown_size(out: &mut Vec, len: usize) { - out.push(0xFFu8 >> (len - 1)); - out.extend(std::iter::repeat_n(0xFF, len - 1)); -} - -/// Big-endian unsigned integer of 0..=8 bytes. -pub fn uint(data: &[u8]) -> u64 { - data.iter().take(8).fold(0u64, |a, &b| (a << 8) | b as u64) -} - -/// Big-endian two's-complement signed integer of 0..=8 bytes. -pub fn int(data: &[u8]) -> i64 { - let n = data.len().min(8); - if n == 0 { - return 0; - } - let v = uint(&data[..n]); - let shift = 64 - 8 * n as u32; - ((v << shift) as i64) >> shift -} - -/// IEEE float of 0, 4 or 8 bytes. -pub fn float(data: &[u8]) -> f64 { - match data.len() { - 4 => f32::from_be_bytes(data.try_into().unwrap_or([0; 4])) as f64, - 8 => f64::from_be_bytes(data.try_into().unwrap_or([0; 8])), - _ => 0.0, - } -} - -/// String / UTF-8 value: bytes up to the first NUL, lossily decoded. -pub fn string(data: &[u8]) -> String { - let end = data.iter().position(|&b| b == 0).unwrap_or(data.len()); - String::from_utf8_lossy(&data[..end]).into_owned() -} - -/// One element inside an in-memory master element. -#[derive(Clone, Copy, Debug)] -pub struct Elem<'a> { - pub id: u32, - /// Offset of the element header, relative to the start of the parent's data. - pub offset: usize, - pub header_len: usize, - pub data: &'a [u8], - /// The size field was "unknown" (data runs to the end of the parent). - pub unknown_size: bool, - /// The declared size overran the parent and was truncated. - pub truncated: bool, -} - -/// Iterator over the children of an in-memory master element. -/// -/// Damaged data ends the iteration (see [`Children::damaged`]); oversize elements are truncated to -/// the parent's end. -pub struct Children<'a> { - data: &'a [u8], - pos: usize, - damaged: bool, -} - -impl<'a> Children<'a> { - pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0, damaged: false } - } - /// True if iteration stopped on an undecodable header. - pub fn damaged(&self) -> bool { - self.damaged - } -} - -impl<'a> Iterator for Children<'a> { - type Item = Elem<'a>; - fn next(&mut self) -> Option> { - let rest = self.data.get(self.pos..)?; - if rest.is_empty() { - return None; - } - let Some((id, il)) = read_id(rest) else { - self.damaged = true; - self.pos = self.data.len(); - return None; - }; - let Some((size, sl)) = read_size(&rest[il..]) else { - self.damaged = true; - self.pos = self.data.len(); - return None; - }; - let hl = il + sl; - let avail = (rest.len() - hl) as u64; - let (len, unknown, truncated) = match size { - Size::Known(n) if n <= avail => (n as usize, false, false), - Size::Known(_) => (avail as usize, false, true), - Size::Unknown => (avail as usize, true, false), - }; - let e = Elem { id, offset: self.pos, header_len: hl, data: &rest[hl..hl + len], unknown_size: unknown, truncated }; - self.pos += hl + len; - Some(e) - } -} - -/// CRC-32 (ISO 3309 / IEEE 802.3, reflected polynomial 0xEDB88320), as used by EBML CRC-32 elements. -pub fn crc32(data: &[u8]) -> u32 { - crc32_update(0xFFFF_FFFF, data) ^ 0xFFFF_FFFF -} - -/// Incremental form: start with `0xFFFF_FFFF`, finish with `^ 0xFFFF_FFFF`. -pub fn crc32_update(mut crc: u32, data: &[u8]) -> u32 { - static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new(); - let t = TABLE.get_or_init(|| { - let mut t = [0u32; 256]; - for (i, e) in t.iter_mut().enumerate() { - let mut c = i as u32; - for _ in 0..8 { - c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; - } - *e = c; - } - t - }); - for &b in data { - crc = t[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8); - } - crc -} - -/// Check a master element's leading CRC-32 child, if present. `Some(true/false)` if there is one. -pub fn verify_crc(master_data: &[u8]) -> Option { - let mut it = Children::new(master_data); - let first = it.next()?; - if first.id != crate::ids::CRC32 || first.data.len() != 4 { - return None; - } - let stored = u32::from_le_bytes(first.data.try_into().ok()?); - let rest = &master_data[first.offset + first.header_len + 4..]; - Some(crc32(rest) == stored) -} - -/// Block lacing mode (flags bits 1–2). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Lacing { - None, - Xiph, - FixedSize, - Ebml, -} - -impl Lacing { - pub fn from_flags(flags: u8) -> Lacing { - match (flags >> 1) & 3 { - 0 => Lacing::None, - 1 => Lacing::Xiph, - 2 => Lacing::FixedSize, - _ => Lacing::Ebml, - } - } -} - -/// Split laced block payload. `data` starts right after the block's flags byte and runs to the end -/// of the block. Returns `(offset, len)` of each frame relative to `data`. -pub fn parse_lacing(lacing: Lacing, data: &[u8]) -> Result> { - if lacing == Lacing::None { - return Ok(vec![(0, data.len())]); - } - let Some(&n1) = data.first() else { return invalid("laced block without frame count") }; - let count = n1 as usize + 1; - let mut pos = 1usize; - let mut sizes = Vec::with_capacity(count); - match lacing { - Lacing::None => return Ok(vec![(0, data.len())]), - Lacing::Xiph => { - for _ in 0..count - 1 { - let mut s = 0usize; - loop { - let Some(&b) = data.get(pos) else { return invalid("truncated Xiph lace header") }; - pos += 1; - s += b as usize; - if b != 255 { - break; - } - } - sizes.push(s); - } - } - Lacing::Ebml => { - if count > 1 { - let Some((first, l, _)) = read_vint(&data[pos..]) else { return invalid("bad EBML lace size") }; - pos += l; - let mut prev = first as i64; - sizes.push(first as usize); - for _ in 1..count - 1 { - let Some((d, l)) = read_svint(&data[pos..]) else { return invalid("bad EBML lace delta") }; - pos += l; - prev += d; - if prev < 0 { - return invalid("negative EBML lace size"); - } - sizes.push(prev as usize); - } - } - } - Lacing::FixedSize => { - let rest = data.len() - pos; - if !rest.is_multiple_of(count) { - return invalid("fixed-size lacing does not divide block"); - } - sizes.extend(std::iter::repeat_n(rest / count, count - 1)); - } - } - let used: usize = sizes.iter().try_fold(0usize, |a, &s| a.checked_add(s)).unwrap_or(usize::MAX); - if pos > data.len() || used > data.len() - pos { - return invalid("lace sizes exceed block"); - } - sizes.push(data.len() - pos - used); - let mut out = Vec::with_capacity(count); - for s in sizes { - out.push((pos, s)); - pos += s; - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn vint_roundtrip() { - for &v in &[0u64, 1, 126, 127, 128, 16382, 16383, 16384, 1 << 20, (1 << 56) - 2] { - let mut b = Vec::new(); - write_size(&mut b, v, 1); - let (s, l) = read_size(&b).unwrap(); - assert_eq!(s, Size::Known(v), "value {v}"); - assert_eq!(l, b.len()); - } - // 127 needs 2 bytes because 0xFF is "unknown" - let mut b = Vec::new(); - write_size(&mut b, 127, 1); - assert_eq!(b, [0x40, 0x7F]); - // padded length - let mut b = Vec::new(); - write_size(&mut b, 5, 8); - assert_eq!(b, [1, 0, 0, 0, 0, 0, 0, 5]); - assert_eq!(read_size(&b).unwrap(), (Size::Known(5), 8)); - } - - #[test] - fn vint_unknown_and_invalid() { - assert_eq!(read_size(&[0xFF]).unwrap(), (Size::Unknown, 1)); - assert_eq!(read_size(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]).unwrap(), (Size::Unknown, 8)); - for l in 1..=8 { - let mut b = Vec::new(); - write_unknown_size(&mut b, l); - assert_eq!(read_size(&b).unwrap(), (Size::Unknown, l)); - } - assert!(read_size(&[0x00, 1, 2]).is_none()); - assert!(read_size(&[0x40]).is_none()); // truncated - assert_eq!(read_vint(&[0x81]).unwrap(), (1, 1, false)); - assert_eq!(read_vint(&[0x10, 0x00, 0x00, 0x01]).unwrap(), (1, 4, false)); - } - - #[test] - fn ids() { - assert_eq!(read_id(&[0x1A, 0x45, 0xDF, 0xA3, 0x99]).unwrap(), (crate::ids::EBML, 4)); - assert_eq!(read_id(&[0xA3]).unwrap(), (0xA3, 1)); - assert_eq!(read_id(&[0x42, 0x86]).unwrap(), (0x4286, 2)); - assert!(read_id(&[0xFF]).is_none()); // reserved - assert_eq!(read_id(&[0x80]).unwrap(), (0x80, 1)); // ChapterDisplay - assert!(read_id(&[0x40, 0x00]).is_none()); // zero value - assert!(read_id(&[0x08, 0, 0, 0, 1]).is_none()); // 5-byte ID - for id in [0xA3u32, 0x4286, 0x2AD7B1, 0x1F43B675] { - let mut b = Vec::new(); - write_id(&mut b, id); - assert_eq!(read_id(&b).unwrap(), (id, b.len())); - } - } - - #[test] - fn signed_vint() { - assert_eq!(read_svint(&[0xBF]).unwrap(), (0, 1)); // 63 - 63 - assert_eq!(read_svint(&[0x80]).unwrap(), (-63, 1)); - assert_eq!(read_svint(&[0xC0]).unwrap(), (1, 1)); - assert_eq!(read_svint(&[0x5F, 0xFF]).unwrap(), (0, 2)); - assert_eq!(read_svint(&[0x60, 0x00]).unwrap(), (1, 2)); - } - - #[test] - fn values() { - assert_eq!(uint(&[]), 0); - assert_eq!(uint(&[1, 0]), 256); - assert_eq!(int(&[0xFF]), -1); - assert_eq!(int(&[0xFF, 0xFE]), -2); - assert_eq!(int(&[0x7F]), 127); - assert_eq!(float(&1.5f32.to_be_bytes()), 1.5); - assert_eq!(float(&2.25f64.to_be_bytes()), 2.25); - assert_eq!(string(b"abc\0\0"), "abc"); - } - - #[test] - fn crc() { - assert_eq!(crc32(b"123456789"), 0xCBF4_3926); - let payload = [0x42u8, 0x86, 0x81, 0x01]; - let mut m = vec![0xBF, 0x84]; - m.extend_from_slice(&crc32(&payload).to_le_bytes()); - m.extend_from_slice(&payload); - assert_eq!(verify_crc(&m), Some(true)); - m[7] ^= 1; - assert_eq!(verify_crc(&m), Some(false)); - assert_eq!(verify_crc(&payload), None); - } - - #[test] - fn children_iter() { - // two elements, then one whose size overruns - let d = [0x42, 0x86, 0x81, 0x01, 0xEC, 0x82, 0, 0, 0xA3, 0x85, 1, 2]; - let v: Vec<_> = Children::new(&d).collect(); - assert_eq!(v.len(), 3); - assert_eq!(v[0].id, 0x4286); - assert_eq!(v[0].data, &[1]); - assert_eq!(v[1].id, 0xEC); - assert_eq!(v[2].data, &[1, 2]); - assert!(v[2].truncated); - let mut it = Children::new(&[0x00, 0x01]); - assert!(it.next().is_none()); - assert!(it.damaged()); - } - - fn frames<'a>(d: &'a [u8], f: &[(usize, usize)]) -> Vec<&'a [u8]> { - f.iter().map(|&(o, l)| &d[o..o + l]).collect() - } - - #[test] - fn lacing_xiph() { - // 3 frames: 300, 2, rest(3) - let mut d = vec![2u8, 255, 45, 2]; - d.extend(std::iter::repeat_n(7u8, 300)); - d.extend_from_slice(&[1, 2]); - d.extend_from_slice(&[3, 4, 5]); - let f = parse_lacing(Lacing::Xiph, &d).unwrap(); - assert_eq!(f.iter().map(|x| x.1).collect::>(), [300, 2, 3]); - assert_eq!(frames(&d, &f)[2], &[3, 4, 5]); - // exact multiple of 255 needs a terminating 0 - let mut d = vec![1u8, 255, 0]; - d.extend(std::iter::repeat_n(1u8, 255)); - d.push(9); - let f = parse_lacing(Lacing::Xiph, &d).unwrap(); - assert_eq!(f.iter().map(|x| x.1).collect::>(), [255, 1]); - assert!(parse_lacing(Lacing::Xiph, &[1, 200, 1]).is_err()); - } - - #[test] - fn lacing_ebml() { - // 4 frames: 5, 3 (delta -2), 4 (delta +1), rest 2 - let mut d = vec![3u8, 0x85, 0xBF - 2, 0xBF + 1]; - d.extend_from_slice(&[1; 5]); - d.extend_from_slice(&[2; 3]); - d.extend_from_slice(&[3; 4]); - d.extend_from_slice(&[4; 2]); - let f = parse_lacing(Lacing::Ebml, &d).unwrap(); - assert_eq!(f.iter().map(|x| x.1).collect::>(), [5, 3, 4, 2]); - assert_eq!(frames(&d, &f), [&[1u8; 5][..], &[2; 3], &[3; 4], &[4; 2]]); - assert!(parse_lacing(Lacing::Ebml, &[1, 0x90]).is_err()); - } - - #[test] - fn lacing_fixed_and_none() { - let d = [2u8, 1, 1, 2, 2, 3, 3]; - let f = parse_lacing(Lacing::FixedSize, &d).unwrap(); - assert_eq!(frames(&d, &f), [&[1u8, 1][..], &[2, 2], &[3, 3]]); - assert!(parse_lacing(Lacing::FixedSize, &[2, 1, 1]).is_err()); - assert_eq!(parse_lacing(Lacing::None, &[9, 9]).unwrap(), [(0, 2)]); - assert_eq!(Lacing::from_flags(0x80 | 0x06), Lacing::Ebml); - assert_eq!(Lacing::from_flags(0x02), Lacing::Xiph); - assert_eq!(Lacing::from_flags(0x04), Lacing::FixedSize); - } -} diff --git a/crates/matroska/src/error.rs b/crates/matroska/src/error.rs deleted file mode 100644 index 718c5c505..000000000 --- a/crates/matroska/src/error.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::fmt; -use std::io; - -/// Errors produced by the demuxer. -#[derive(Debug)] -pub enum Error { - /// Underlying I/O failure. - Io(io::Error), - /// Structurally invalid data that could not be recovered from. - Invalid(String), - /// Valid but unsupported feature (e.g. zlib-compressed or encrypted tracks when reading data). - Unsupported(String), - /// Not an EBML file, or an EBML file whose DocType is not `matroska`/`webm`. - NotMatroska(String), - /// A track index was out of range. - NoSuchTrack(usize), - /// A sample index was out of range. - NoSuchSample(usize), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Io(e) => write!(f, "I/O error: {e}"), - Error::Invalid(s) => write!(f, "invalid data: {s}"), - Error::Unsupported(s) => write!(f, "unsupported: {s}"), - Error::NotMatroska(s) => write!(f, "not a Matroska/WebM file: {s}"), - Error::NoSuchTrack(i) => write!(f, "track index {i} out of range"), - Error::NoSuchSample(i) => write!(f, "sample index {i} out of range"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Error::Io(e) => Some(e), - _ => None, - } - } -} - -impl From for Error { - fn from(e: io::Error) -> Self { - Error::Io(e) - } -} - -pub type Result = std::result::Result; - -pub(crate) fn invalid(msg: impl Into) -> Result { - Err(Error::Invalid(msg.into())) -} diff --git a/crates/matroska/src/ids.rs b/crates/matroska/src/ids.rs deleted file mode 100644 index 3b7e025c4..000000000 --- a/crates/matroska/src/ids.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! EBML / Matroska element IDs (RFC 8794, RFC 9559). IDs keep their VINT marker bits. -#![allow(dead_code)] - -// EBML header + global elements -pub const EBML: u32 = 0x1A45_DFA3; -pub const EBML_VERSION: u32 = 0x4286; -pub const EBML_READ_VERSION: u32 = 0x42F7; -pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2; -pub const EBML_MAX_SIZE_LENGTH: u32 = 0x42F3; -pub const DOC_TYPE: u32 = 0x4282; -pub const DOC_TYPE_VERSION: u32 = 0x4287; -pub const DOC_TYPE_READ_VERSION: u32 = 0x4285; -pub const CRC32: u32 = 0xBF; -pub const VOID: u32 = 0xEC; - -// Segment and level-1 elements -pub const SEGMENT: u32 = 0x1853_8067; -pub const SEEK_HEAD: u32 = 0x114D_9B74; -pub const INFO: u32 = 0x1549_A966; -pub const TRACKS: u32 = 0x1654_AE6B; -pub const CLUSTER: u32 = 0x1F43_B675; -pub const CUES: u32 = 0x1C53_BB6B; -pub const ATTACHMENTS: u32 = 0x1941_A469; -pub const CHAPTERS: u32 = 0x1043_A770; -pub const TAGS: u32 = 0x1254_C367; - -/// True for IDs that can only appear directly inside a Segment (or are the EBML/Segment headers): -/// used to find the end of unknown-size elements. -pub fn is_top_level(id: u32) -> bool { - matches!(id, EBML | SEGMENT | SEEK_HEAD | INFO | TRACKS | CLUSTER | CUES | ATTACHMENTS | CHAPTERS | TAGS) -} - -// SeekHead -pub const SEEK: u32 = 0x4DBB; -pub const SEEK_ID: u32 = 0x53AB; -pub const SEEK_POSITION: u32 = 0x53AC; - -// Info -pub const SEGMENT_UUID: u32 = 0x73A4; -pub const SEGMENT_FILENAME: u32 = 0x7384; -pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1; -pub const DURATION: u32 = 0x4489; -pub const DATE_UTC: u32 = 0x4461; -pub const TITLE: u32 = 0x7BA9; -pub const MUXING_APP: u32 = 0x4D80; -pub const WRITING_APP: u32 = 0x5741; - -// Cluster -pub const TIMESTAMP: u32 = 0xE7; -pub const POSITION: u32 = 0xA7; -pub const PREV_SIZE: u32 = 0xAB; -pub const SIMPLE_BLOCK: u32 = 0xA3; -pub const BLOCK_GROUP: u32 = 0xA0; -pub const ENCRYPTED_BLOCK: u32 = 0xAF; -pub const SILENT_TRACKS: u32 = 0x5854; -pub const BLOCK: u32 = 0xA1; -pub const BLOCK_ADDITIONS: u32 = 0x75A1; -pub const BLOCK_MORE: u32 = 0xA6; -pub const BLOCK_ADD_ID: u32 = 0xEE; -pub const BLOCK_ADDITIONAL: u32 = 0xA5; -pub const BLOCK_DURATION: u32 = 0x9B; -pub const REFERENCE_PRIORITY: u32 = 0xFA; -pub const REFERENCE_BLOCK: u32 = 0xFB; -pub const CODEC_STATE: u32 = 0xA4; -pub const DISCARD_PADDING: u32 = 0x75A2; - -pub fn is_cluster_child(id: u32) -> bool { - matches!(id, TIMESTAMP | POSITION | PREV_SIZE | SIMPLE_BLOCK | BLOCK_GROUP | ENCRYPTED_BLOCK | SILENT_TRACKS | CRC32 | VOID) -} - -// Tracks -pub const TRACK_ENTRY: u32 = 0xAE; -pub const TRACK_NUMBER: u32 = 0xD7; -pub const TRACK_UID: u32 = 0x73C5; -pub const TRACK_TYPE: u32 = 0x83; -pub const FLAG_ENABLED: u32 = 0xB9; -pub const FLAG_DEFAULT: u32 = 0x88; -pub const FLAG_FORCED: u32 = 0x55AA; -pub const FLAG_HEARING_IMPAIRED: u32 = 0x55AB; -pub const FLAG_VISUAL_IMPAIRED: u32 = 0x55AC; -pub const FLAG_ORIGINAL: u32 = 0x55AE; -pub const FLAG_COMMENTARY: u32 = 0x55AF; -pub const FLAG_LACING: u32 = 0x9C; -pub const DEFAULT_DURATION: u32 = 0x23_E383; -pub const TRACK_TIMESTAMP_SCALE: u32 = 0x23_314F; -pub const NAME: u32 = 0x536E; -pub const LANGUAGE: u32 = 0x22_B59C; -pub const LANGUAGE_BCP47: u32 = 0x22_B59D; -pub const CODEC_ID: u32 = 0x86; -pub const CODEC_PRIVATE: u32 = 0x63A2; -pub const CODEC_NAME: u32 = 0x25_8688; -pub const CODEC_DELAY: u32 = 0x56AA; -pub const SEEK_PRE_ROLL: u32 = 0x56BB; - -// Video -pub const VIDEO: u32 = 0xE0; -pub const FLAG_INTERLACED: u32 = 0x9A; -pub const FIELD_ORDER: u32 = 0x9D; -pub const STEREO_MODE: u32 = 0x53B8; -pub const ALPHA_MODE: u32 = 0x53C0; -pub const PIXEL_WIDTH: u32 = 0xB0; -pub const PIXEL_HEIGHT: u32 = 0xBA; -pub const PIXEL_CROP_BOTTOM: u32 = 0x54AA; -pub const PIXEL_CROP_TOP: u32 = 0x54BB; -pub const PIXEL_CROP_LEFT: u32 = 0x54CC; -pub const PIXEL_CROP_RIGHT: u32 = 0x54DD; -pub const DISPLAY_WIDTH: u32 = 0x54B0; -pub const DISPLAY_HEIGHT: u32 = 0x54BA; -pub const DISPLAY_UNIT: u32 = 0x54B2; -pub const COLOUR_SPACE: u32 = 0x2E_B524; -pub const COLOUR: u32 = 0x55B0; -pub const PROJECTION: u32 = 0x7670; -pub const PROJECTION_TYPE: u32 = 0x7671; -pub const PROJECTION_POSE_YAW: u32 = 0x7673; -pub const PROJECTION_POSE_PITCH: u32 = 0x7674; -pub const PROJECTION_POSE_ROLL: u32 = 0x7675; - -// Colour -pub const MATRIX_COEFFICIENTS: u32 = 0x55B1; -pub const BITS_PER_CHANNEL: u32 = 0x55B2; -pub const CHROMA_SUBSAMPLING_HORZ: u32 = 0x55B3; -pub const CHROMA_SUBSAMPLING_VERT: u32 = 0x55B4; -pub const CB_SUBSAMPLING_HORZ: u32 = 0x55B5; -pub const CB_SUBSAMPLING_VERT: u32 = 0x55B6; -pub const CHROMA_SITING_HORZ: u32 = 0x55B7; -pub const CHROMA_SITING_VERT: u32 = 0x55B8; -pub const RANGE: u32 = 0x55B9; -pub const TRANSFER_CHARACTERISTICS: u32 = 0x55BA; -pub const PRIMARIES: u32 = 0x55BB; -pub const MAX_CLL: u32 = 0x55BC; -pub const MAX_FALL: u32 = 0x55BD; -pub const MASTERING_METADATA: u32 = 0x55D0; -pub const PRIMARY_R_X: u32 = 0x55D1; -pub const PRIMARY_R_Y: u32 = 0x55D2; -pub const PRIMARY_G_X: u32 = 0x55D3; -pub const PRIMARY_G_Y: u32 = 0x55D4; -pub const PRIMARY_B_X: u32 = 0x55D5; -pub const PRIMARY_B_Y: u32 = 0x55D6; -pub const WHITE_POINT_X: u32 = 0x55D7; -pub const WHITE_POINT_Y: u32 = 0x55D8; -pub const LUMINANCE_MAX: u32 = 0x55D9; -pub const LUMINANCE_MIN: u32 = 0x55DA; - -// Audio -pub const AUDIO: u32 = 0xE1; -pub const SAMPLING_FREQUENCY: u32 = 0xB5; -pub const OUTPUT_SAMPLING_FREQUENCY: u32 = 0x78B5; -pub const CHANNELS: u32 = 0x9F; -pub const BIT_DEPTH: u32 = 0x6264; - -// ContentEncodings -pub const CONTENT_ENCODINGS: u32 = 0x6D80; -pub const CONTENT_ENCODING: u32 = 0x6240; -pub const CONTENT_ENCODING_ORDER: u32 = 0x5031; -pub const CONTENT_ENCODING_SCOPE: u32 = 0x5032; -pub const CONTENT_ENCODING_TYPE: u32 = 0x5033; -pub const CONTENT_COMPRESSION: u32 = 0x5034; -pub const CONTENT_COMP_ALGO: u32 = 0x4254; -pub const CONTENT_COMP_SETTINGS: u32 = 0x4255; -pub const CONTENT_ENCRYPTION: u32 = 0x5035; - -// Cues -pub const CUE_POINT: u32 = 0xBB; -pub const CUE_TIME: u32 = 0xB3; -pub const CUE_TRACK_POSITIONS: u32 = 0xB7; -pub const CUE_TRACK: u32 = 0xF7; -pub const CUE_CLUSTER_POSITION: u32 = 0xF1; -pub const CUE_RELATIVE_POSITION: u32 = 0xF0; -pub const CUE_DURATION: u32 = 0xB2; -pub const CUE_BLOCK_NUMBER: u32 = 0x5378; - -// Attachments -pub const ATTACHED_FILE: u32 = 0x61A7; -pub const FILE_DESCRIPTION: u32 = 0x467E; -pub const FILE_NAME: u32 = 0x466E; -pub const FILE_MEDIA_TYPE: u32 = 0x4660; -pub const FILE_DATA: u32 = 0x465C; -pub const FILE_UID: u32 = 0x46AE; - -// Chapters -pub const EDITION_ENTRY: u32 = 0x45B9; -pub const EDITION_UID: u32 = 0x45BC; -pub const EDITION_FLAG_HIDDEN: u32 = 0x45BD; -pub const EDITION_FLAG_DEFAULT: u32 = 0x45DB; -pub const EDITION_FLAG_ORDERED: u32 = 0x45DD; -pub const CHAPTER_ATOM: u32 = 0xB6; -pub const CHAPTER_UID: u32 = 0x73C4; -pub const CHAPTER_STRING_UID: u32 = 0x5654; -pub const CHAPTER_TIME_START: u32 = 0x91; -pub const CHAPTER_TIME_END: u32 = 0x92; -pub const CHAPTER_FLAG_HIDDEN: u32 = 0x98; -pub const CHAPTER_FLAG_ENABLED: u32 = 0x4598; -pub const CHAPTER_DISPLAY: u32 = 0x80; -pub const CHAP_STRING: u32 = 0x85; -pub const CHAP_LANGUAGE: u32 = 0x437C; -pub const CHAP_LANGUAGE_BCP47: u32 = 0x437D; -pub const CHAP_COUNTRY: u32 = 0x437E; - -// Tags -pub const TAG: u32 = 0x7373; -pub const TARGETS: u32 = 0x63C0; -pub const TARGET_TYPE_VALUE: u32 = 0x68CA; -pub const TARGET_TYPE: u32 = 0x63CA; -pub const TAG_TRACK_UID: u32 = 0x63C5; -pub const TAG_EDITION_UID: u32 = 0x63C9; -pub const TAG_CHAPTER_UID: u32 = 0x63C4; -pub const TAG_ATTACHMENT_UID: u32 = 0x63C6; -pub const SIMPLE_TAG: u32 = 0x67C8; -pub const TAG_NAME: u32 = 0x45A3; -pub const TAG_LANGUAGE: u32 = 0x447A; -pub const TAG_LANGUAGE_BCP47: u32 = 0x447B; -pub const TAG_DEFAULT: u32 = 0x4484; -pub const TAG_STRING: u32 = 0x4487; -pub const TAG_BINARY: u32 = 0x4485; diff --git a/crates/matroska/src/lib.rs b/crates/matroska/src/lib.rs index 5901e5e97..94428f619 100644 --- a/crates/matroska/src/lib.rs +++ b/crates/matroska/src/lib.rs @@ -1,38 +1,6 @@ -//! Clean-room Matroska (MKV) and WebM demuxer, written from RFC 8794 (EBML), RFC 9559 (Matroska) -//! and the WebM container guidelines. -//! -//! - [`open`] / [`open_with`] parse the EBML header and the first Segment (Info, Tracks, SeekHead, -//! Cues, Chapters, Tags, Attachments) and, by default, scan every Cluster's block headers to build -//! per-track [`Sample`] tables — the same shape as `filmcraft-isobmff`, so callers can treat both -//! containers alike. Frame data is read on demand with [`MkvFile::read_sample`]. -//! - [`Demuxer`] iterates [`Packet`]s in file order (from any [`ByteSource`], `Read + Seek`, or a -//! byte slice) and seeks to the keyframe preceding a time, via the index, Cues, or a cluster scan. -//! -//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. +//! Matroska (MKV) and WebM demuxer and muxer: a re-export of [`craft_media::matroska`] from +//! craft-libs. Supported elements and limitations are in the craft-media README. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -mod codec; -mod demux; -pub mod ebml; -mod error; -mod ids; -mod meta; -mod mux; -mod source; -#[cfg(test)] -mod synthetic_tests; -mod track; - -pub use codec::Codec; -pub use demux::{Demuxer, Keyframe, MkvFile, OpenOptions, Packet, SeekPoint, open, open_with}; -pub use error::{Error, Result}; -pub use meta::{Attachment, Chapter, ChapterDisplay, ClusterInfo, CuePoint, CuePosition, Edition, SeekEntry, SegmentInfo, SimpleTag, Tag, TagTargets}; -pub use mux::{LacingMode, MkvWriter, MuxOptions, TrackSpec}; -pub use source::{ByteSource, ReadSeekSource}; -pub use track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Sample, Track, TrackKind, VideoInfo}; - -/// Element IDs used by the demuxer (marker bits included), for callers inspecting raw elements. -pub mod element_ids { - pub use crate::ids::*; -} +pub use craft_media::matroska::*; diff --git a/crates/matroska/src/meta.rs b/crates/matroska/src/meta.rs deleted file mode 100644 index 7406be14f..000000000 --- a/crates/matroska/src/meta.rs +++ /dev/null @@ -1,572 +0,0 @@ -//! Segment-level metadata: Info, SeekHead, Cues, Chapters, Tags, Attachments — types and parsers -//! for their in-memory element data. - -use crate::codec::map_codec; -use crate::ebml::{Children, float, int, string, uint}; -use crate::ids::*; -use crate::track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Track, TrackKind, VideoInfo, gcd}; - -// A malformed Matroska file may contain arbitrarily deep ChapterAtom and SimpleTag chains. -const MAX_META_DEPTH: usize = 64; - -/// `Info` element. -#[derive(Clone, Debug, PartialEq)] -pub struct SegmentInfo { - /// Nanoseconds per timestamp tick (default 1 000 000 = 1 ms). - pub timestamp_scale: u64, - /// `Duration` in timestamp ticks (float), if present. - pub duration: Option, - pub title: Option, - pub muxing_app: String, - pub writing_app: String, - /// `DateUTC`: nanoseconds since 2001-01-01T00:00:00 UTC. - pub date_utc: Option, - pub segment_uuid: Option<[u8; 16]>, -} - -impl Default for SegmentInfo { - fn default() -> Self { - Self { - timestamp_scale: 1_000_000, - duration: None, - title: None, - muxing_app: String::new(), - writing_app: String::new(), - date_utc: None, - segment_uuid: None, - } - } -} - -/// One `SeekHead` entry: level-1 element ID and its position relative to the Segment data start. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SeekEntry { - pub id: u32, - pub position: u64, -} - -/// One `CueTrackPositions` of a `CuePoint`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CuePosition { - pub track: u64, - /// Cluster position relative to the Segment data start. - pub cluster_position: u64, - /// Block position relative to the Cluster data start. - pub relative_position: Option, - pub block_number: Option, - pub duration: Option, -} - -/// One `CuePoint`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CuePoint { - /// In timestamp ticks. - pub time: u64, - pub positions: Vec, -} - -/// A cluster found while scanning. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ClusterInfo { - /// Absolute offset of the Cluster element header. - pub offset: u64, - /// Absolute offset of the first child. - pub data_start: u64, - /// Absolute end offset (for unknown-size clusters, where the next top-level element starts). - pub end: u64, - /// Cluster `Timestamp` in ticks. - pub timestamp: u64, -} - -/// One `ChapterDisplay`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ChapterDisplay { - pub string: String, - pub languages: Vec, - pub languages_bcp47: Vec, - pub countries: Vec, -} - -/// `ChapterAtom` (possibly nested). -#[derive(Clone, Debug, PartialEq)] -pub struct Chapter { - pub uid: u64, - pub string_uid: Option, - pub start_ns: u64, - pub end_ns: Option, - pub hidden: bool, - pub enabled: bool, - pub displays: Vec, - pub children: Vec, -} - -impl Chapter { - /// First display string, if any. - pub fn title(&self) -> Option<&str> { - self.displays.first().map(|d| d.string.as_str()) - } -} - -/// `EditionEntry`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Edition { - pub uid: Option, - pub hidden: bool, - pub default: bool, - pub ordered: bool, - pub chapters: Vec, -} - -/// `Targets` of a `Tag`. Empty UID lists mean "the whole segment". -#[derive(Clone, Debug, PartialEq)] -pub struct TagTargets { - pub type_value: u64, - pub type_name: Option, - pub track_uids: Vec, - pub edition_uids: Vec, - pub chapter_uids: Vec, - pub attachment_uids: Vec, -} - -impl Default for TagTargets { - fn default() -> Self { - Self { type_value: 50, type_name: None, track_uids: Vec::new(), edition_uids: Vec::new(), chapter_uids: Vec::new(), attachment_uids: Vec::new() } - } -} - -/// `SimpleTag` (possibly nested). -#[derive(Clone, Debug, PartialEq)] -pub struct SimpleTag { - pub name: String, - pub language: String, - pub default: bool, - pub string: Option, - pub binary: Option>, - pub children: Vec, -} - -/// `Tag`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Tag { - pub targets: TagTargets, - pub simple: Vec, -} - -/// `AttachedFile` (listed only; the payload is located by `data_offset`/`data_size`). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Attachment { - pub uid: u64, - pub name: String, - pub media_type: String, - pub description: Option, - pub data_offset: u64, - pub data_size: u64, -} - -pub(crate) fn parse_info(d: &[u8]) -> SegmentInfo { - let mut info = SegmentInfo::default(); - for e in Children::new(d) { - match e.id { - TIMESTAMP_SCALE => { - let v = uint(e.data); - if v > 0 { - info.timestamp_scale = v; - } - } - DURATION => info.duration = Some(float(e.data)).filter(|v| v.is_finite() && *v >= 0.0), - TITLE => info.title = Some(string(e.data)), - MUXING_APP => info.muxing_app = string(e.data), - WRITING_APP => info.writing_app = string(e.data), - DATE_UTC => info.date_utc = Some(int(e.data)), - SEGMENT_UUID => info.segment_uuid = e.data.try_into().ok(), - _ => {} - } - } - info -} - -pub(crate) fn parse_seekhead(d: &[u8]) -> Vec { - let mut out = Vec::new(); - for s in Children::new(d).filter(|e| e.id == SEEK) { - let (mut id, mut pos) = (None, None); - for e in Children::new(s.data) { - match e.id { - SEEK_ID => id = Some(uint(e.data) as u32), - SEEK_POSITION => pos = Some(uint(e.data)), - _ => {} - } - } - if let (Some(id), Some(position)) = (id, pos) { - out.push(SeekEntry { id, position }); - } - } - out -} - -pub(crate) fn parse_cues(d: &[u8], out: &mut Vec) { - for cp in Children::new(d).filter(|e| e.id == CUE_POINT) { - let mut time = None; - let mut positions = Vec::new(); - for e in Children::new(cp.data) { - match e.id { - CUE_TIME => time = Some(uint(e.data)), - CUE_TRACK_POSITIONS => { - let mut p = CuePosition { track: 0, cluster_position: u64::MAX, relative_position: None, block_number: None, duration: None }; - for c in Children::new(e.data) { - match c.id { - CUE_TRACK => p.track = uint(c.data), - CUE_CLUSTER_POSITION => p.cluster_position = uint(c.data), - CUE_RELATIVE_POSITION => p.relative_position = Some(uint(c.data)), - CUE_BLOCK_NUMBER => p.block_number = Some(uint(c.data)), - CUE_DURATION => p.duration = Some(uint(c.data)), - _ => {} - } - } - if p.track != 0 && p.cluster_position != u64::MAX { - positions.push(p); - } - } - _ => {} - } - } - if let Some(time) = time - && !positions.is_empty() - { - out.push(CuePoint { time, positions }); - } - } -} - -fn parse_chapter(d: &[u8], depth: usize) -> Chapter { - let mut c = Chapter { uid: 0, string_uid: None, start_ns: 0, end_ns: None, hidden: false, enabled: true, displays: Vec::new(), children: Vec::new() }; - for e in Children::new(d) { - match e.id { - CHAPTER_UID => c.uid = uint(e.data), - CHAPTER_STRING_UID => c.string_uid = Some(string(e.data)), - CHAPTER_TIME_START => c.start_ns = uint(e.data), - CHAPTER_TIME_END => c.end_ns = Some(uint(e.data)), - CHAPTER_FLAG_HIDDEN => c.hidden = uint(e.data) != 0, - CHAPTER_FLAG_ENABLED => c.enabled = uint(e.data) != 0, - CHAPTER_DISPLAY => { - let mut disp = ChapterDisplay::default(); - for x in Children::new(e.data) { - match x.id { - CHAP_STRING => disp.string = string(x.data), - CHAP_LANGUAGE => disp.languages.push(string(x.data)), - CHAP_LANGUAGE_BCP47 => disp.languages_bcp47.push(string(x.data)), - CHAP_COUNTRY => disp.countries.push(string(x.data)), - _ => {} - } - } - if disp.languages.is_empty() { - disp.languages.push("eng".into()); - } - c.displays.push(disp); - } - CHAPTER_ATOM if depth < MAX_META_DEPTH => c.children.push(parse_chapter(e.data, depth + 1)), - _ => {} - } - } - c -} - -pub(crate) fn parse_chapters(d: &[u8], out: &mut Vec) { - for ed in Children::new(d).filter(|e| e.id == EDITION_ENTRY) { - let mut edition = Edition::default(); - for e in Children::new(ed.data) { - match e.id { - EDITION_UID => edition.uid = Some(uint(e.data)), - EDITION_FLAG_HIDDEN => edition.hidden = uint(e.data) != 0, - EDITION_FLAG_DEFAULT => edition.default = uint(e.data) != 0, - EDITION_FLAG_ORDERED => edition.ordered = uint(e.data) != 0, - CHAPTER_ATOM => edition.chapters.push(parse_chapter(e.data, 0)), - _ => {} - } - } - out.push(edition); - } -} - -fn parse_simple_tag(d: &[u8], depth: usize) -> SimpleTag { - let mut t = SimpleTag { name: String::new(), language: "und".into(), default: true, string: None, binary: None, children: Vec::new() }; - for e in Children::new(d) { - match e.id { - TAG_NAME => t.name = string(e.data), - TAG_LANGUAGE => t.language = string(e.data), - TAG_DEFAULT => t.default = uint(e.data) != 0, - TAG_STRING => t.string = Some(string(e.data)), - TAG_BINARY => t.binary = Some(e.data.to_vec()), - SIMPLE_TAG if depth < MAX_META_DEPTH => t.children.push(parse_simple_tag(e.data, depth + 1)), - _ => {} - } - } - t -} - -pub(crate) fn parse_tags(d: &[u8], out: &mut Vec) { - for tg in Children::new(d).filter(|e| e.id == TAG) { - let mut tag = Tag::default(); - for e in Children::new(tg.data) { - match e.id { - TARGETS => { - for x in Children::new(e.data) { - match x.id { - TARGET_TYPE_VALUE => tag.targets.type_value = uint(x.data), - TARGET_TYPE => tag.targets.type_name = Some(string(x.data)), - TAG_TRACK_UID => tag.targets.track_uids.push(uint(x.data)), - TAG_EDITION_UID => tag.targets.edition_uids.push(uint(x.data)), - TAG_CHAPTER_UID => tag.targets.chapter_uids.push(uint(x.data)), - TAG_ATTACHMENT_UID => tag.targets.attachment_uids.push(uint(x.data)), - _ => {} - } - } - } - SIMPLE_TAG => tag.simple.push(parse_simple_tag(e.data, 0)), - _ => {} - } - } - out.push(tag); - } -} - -fn parse_colour(d: &[u8]) -> Colour { - let mut c = Colour::default(); - for e in Children::new(d) { - let v = || Some(uint(e.data)); - match e.id { - MATRIX_COEFFICIENTS => c.matrix_coefficients = v(), - BITS_PER_CHANNEL => c.bits_per_channel = v(), - CHROMA_SUBSAMPLING_HORZ => c.chroma_subsampling_horz = v(), - CHROMA_SUBSAMPLING_VERT => c.chroma_subsampling_vert = v(), - CB_SUBSAMPLING_HORZ => c.cb_subsampling_horz = v(), - CB_SUBSAMPLING_VERT => c.cb_subsampling_vert = v(), - CHROMA_SITING_HORZ => c.chroma_siting_horz = v(), - CHROMA_SITING_VERT => c.chroma_siting_vert = v(), - RANGE => c.range = v(), - TRANSFER_CHARACTERISTICS => c.transfer_characteristics = v(), - PRIMARIES => c.primaries = v(), - MAX_CLL => c.max_cll = v(), - MAX_FALL => c.max_fall = v(), - MASTERING_METADATA => { - let mut m = MasteringMetadata::default(); - for x in Children::new(e.data) { - let f = float(x.data); - match x.id { - PRIMARY_R_X => m.primaries[0].0 = f, - PRIMARY_R_Y => m.primaries[0].1 = f, - PRIMARY_G_X => m.primaries[1].0 = f, - PRIMARY_G_Y => m.primaries[1].1 = f, - PRIMARY_B_X => m.primaries[2].0 = f, - PRIMARY_B_Y => m.primaries[2].1 = f, - WHITE_POINT_X => m.white_point.0 = f, - WHITE_POINT_Y => m.white_point.1 = f, - LUMINANCE_MAX => m.luminance_max = f, - LUMINANCE_MIN => m.luminance_min = f, - _ => {} - } - } - c.mastering = Some(m); - } - _ => {} - } - } - c -} - -fn parse_video(d: &[u8]) -> VideoInfo { - let mut v = VideoInfo::default(); - for e in Children::new(d) { - let u = uint(e.data); - match e.id { - PIXEL_WIDTH => v.pixel_width = u as u32, - PIXEL_HEIGHT => v.pixel_height = u as u32, - DISPLAY_WIDTH => v.display_width = Some(u as u32), - DISPLAY_HEIGHT => v.display_height = Some(u as u32), - DISPLAY_UNIT => v.display_unit = u, - PIXEL_CROP_TOP => v.crop.0 = u as u32, - PIXEL_CROP_BOTTOM => v.crop.1 = u as u32, - PIXEL_CROP_LEFT => v.crop.2 = u as u32, - PIXEL_CROP_RIGHT => v.crop.3 = u as u32, - FLAG_INTERLACED => v.flag_interlaced = u, - FIELD_ORDER => v.field_order = Some(u), - STEREO_MODE => v.stereo_mode = Some(u), - ALPHA_MODE => v.alpha_mode = u, - COLOUR_SPACE => v.colour_space = e.data.try_into().ok(), - COLOUR => v.colour = Some(parse_colour(e.data)), - PROJECTION => { - let mut p = Projection::default(); - for x in Children::new(e.data) { - match x.id { - PROJECTION_TYPE => p.projection_type = uint(x.data), - PROJECTION_POSE_YAW => p.yaw = float(x.data), - PROJECTION_POSE_PITCH => p.pitch = float(x.data), - PROJECTION_POSE_ROLL => p.roll = float(x.data), - _ => {} - } - } - v.projection = Some(p); - } - _ => {} - } - } - v -} - -fn parse_audio(d: &[u8]) -> AudioInfo { - let mut a = AudioInfo::default(); - for e in Children::new(d) { - match e.id { - SAMPLING_FREQUENCY => a.sampling_frequency = float(e.data), - OUTPUT_SAMPLING_FREQUENCY => a.output_sampling_frequency = Some(float(e.data)), - CHANNELS => a.channels = uint(e.data), - BIT_DEPTH => a.bit_depth = Some(uint(e.data)), - _ => {} - } - } - a -} - -fn parse_encodings(d: &[u8]) -> Vec { - let mut list: Vec<(u64, ContentEncoding)> = Vec::new(); - for ce in Children::new(d).filter(|e| e.id == CONTENT_ENCODING) { - let (mut order, mut scope, mut ty) = (0u64, 1u64, 0u64); - let mut comp: Option<(u64, Vec)> = None; - for e in Children::new(ce.data) { - match e.id { - CONTENT_ENCODING_ORDER => order = uint(e.data), - CONTENT_ENCODING_SCOPE => scope = uint(e.data), - CONTENT_ENCODING_TYPE => ty = uint(e.data), - CONTENT_COMPRESSION => { - let (mut algo, mut settings) = (0u64, Vec::new()); - for x in Children::new(e.data) { - match x.id { - CONTENT_COMP_ALGO => algo = uint(x.data), - CONTENT_COMP_SETTINGS => settings = x.data.to_vec(), - _ => {} - } - } - comp = Some((algo, settings)); - } - _ => {} - } - } - let enc = if ty == 1 { - ContentEncoding::Encryption { scope } - } else { - let (algo, settings) = comp.unwrap_or((0, Vec::new())); - if algo == 3 { ContentEncoding::HeaderStripping { bytes: settings, scope } } else { ContentEncoding::Compression { algo, settings, scope } } - }; - list.push((order, enc)); - } - // highest order is applied last on muxing → first to undo; keep decode order - list.sort_by_key(|e| std::cmp::Reverse(e.0)); - list.into_iter().map(|e| e.1).collect() -} - -pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec { - let g = gcd(timestamp_scale, 1_000_000_000); - let timebase = (timestamp_scale / g, 1_000_000_000 / g); - let mut out = Vec::new(); - for te in Children::new(d).filter(|e| e.id == TRACK_ENTRY) { - let mut t = Track { - number: 0, - uid: 0, - kind: TrackKind::Other(0), - enabled: true, - default: true, - forced: false, - hearing_impaired: false, - lacing: true, - default_duration_ns: None, - name: String::new(), - language: "eng".into(), - language_bcp47: None, - codec_id: String::new(), - codec_private: Vec::new(), - codec_name: String::new(), - codec: crate::Codec::Other(String::new()), - codec_delay_ns: 0, - seek_pre_roll_ns: 0, - video: None, - audio: None, - encodings: Vec::new(), - timebase, - samples: Vec::new(), - sync: Vec::new(), - }; - for e in Children::new(te.data) { - match e.id { - TRACK_NUMBER => t.number = uint(e.data), - TRACK_UID => t.uid = uint(e.data), - TRACK_TYPE => t.kind = TrackKind::from_type(uint(e.data)), - FLAG_ENABLED => t.enabled = uint(e.data) != 0, - FLAG_DEFAULT => t.default = uint(e.data) != 0, - FLAG_FORCED => t.forced = uint(e.data) != 0, - FLAG_HEARING_IMPAIRED => t.hearing_impaired = uint(e.data) != 0, - FLAG_LACING => t.lacing = uint(e.data) != 0, - DEFAULT_DURATION => t.default_duration_ns = Some(uint(e.data)).filter(|&v| v > 0), - NAME => t.name = string(e.data), - LANGUAGE => t.language = string(e.data), - LANGUAGE_BCP47 => t.language_bcp47 = Some(string(e.data)), - CODEC_ID => t.codec_id = string(e.data), - CODEC_PRIVATE => t.codec_private = e.data.to_vec(), - CODEC_NAME => t.codec_name = string(e.data), - CODEC_DELAY => t.codec_delay_ns = uint(e.data), - SEEK_PRE_ROLL => t.seek_pre_roll_ns = uint(e.data), - VIDEO => t.video = Some(parse_video(e.data)), - AUDIO => t.audio = Some(parse_audio(e.data)), - CONTENT_ENCODINGS => t.encodings = parse_encodings(e.data), - _ => {} - } - } - if t.number == 0 { - continue; - } - if t.kind == TrackKind::Audio && t.audio.is_none() { - t.audio = Some(AudioInfo::default()); - } - // CodecPrivate may itself be header-stripped (scope bit 2 = private data) - for enc in &t.encodings { - if let ContentEncoding::HeaderStripping { bytes, scope } = enc - && scope & 2 != 0 - { - let mut p = bytes.clone(); - p.extend_from_slice(&t.codec_private); - t.codec_private = p; - } - } - t.codec = map_codec(&t.codec_id, &t.codec_private, t.audio.as_ref()); - out.push(t); - } - out -} - -#[cfg(test)] -mod metadata_depth_tests { - use super::*; - - #[test] - fn deeply_nested_chapters_stop_at_depth_limit() { - // Unknown-size elements make every pair the sole child of the previous ChapterAtom. - let data = [0xB6, 0xFF].repeat(100_000); - let chapter = parse_chapter(&data, 0); - let mut current = &chapter; - for _ in 0..MAX_META_DEPTH { - assert_eq!(current.children.len(), 1); - current = ¤t.children[0]; - } - assert!(current.children.is_empty()); - } - - #[test] - fn deeply_nested_simple_tags_stop_at_depth_limit() { - let data = [0x67, 0xC8, 0xFF].repeat(100_000); - let tag = parse_simple_tag(&data, 0); - let mut current = &tag; - for _ in 0..MAX_META_DEPTH { - assert_eq!(current.children.len(), 1); - current = ¤t.children[0]; - } - assert!(current.children.is_empty()); - } -} diff --git a/crates/matroska/src/mux.rs b/crates/matroska/src/mux.rs deleted file mode 100644 index f809b1beb..000000000 --- a/crates/matroska/src/mux.rs +++ /dev/null @@ -1,539 +0,0 @@ -//! Minimal Matroska / WebM muxer: SimpleBlocks (optionally laced), BlockGroups for explicit -//! durations, Cues, SeekHead, Info with Duration. Output must be `Write + Seek` (sizes and the -//! SeekHead are back-patched on [`MkvWriter::finish`]). - -use std::io::{Seek, SeekFrom, Write}; - -use crate::ebml::{write_id, write_size, write_unknown_size}; -use crate::error::{Error, Result}; -use crate::ids::*; -use crate::track::TrackKind; - -/// Lacing used for audio tracks. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LacingMode { - None, - Xiph, - Ebml, - /// Fixed-size lacing when consecutive frames have equal sizes, otherwise EBML lacing. - FixedOrEbml, -} - -/// Track description for [`MkvWriter`]. -#[derive(Clone, Debug)] -pub struct TrackSpec { - pub kind: TrackKind, - pub codec_id: String, - pub codec_private: Vec, - pub default_duration_ns: Option, - pub language: Option, - pub name: Option, - /// Pixel width/height for video tracks. - pub video_size: Option<(u32, u32)>, - /// (sampling frequency, channels, bit depth) for audio tracks. - pub audio: Option<(f64, u64, Option)>, - pub codec_delay_ns: u64, - pub seek_pre_roll_ns: u64, -} - -impl TrackSpec { - pub fn new(kind: TrackKind, codec_id: impl Into) -> Self { - Self { - kind, - codec_id: codec_id.into(), - codec_private: Vec::new(), - default_duration_ns: None, - language: None, - name: None, - video_size: None, - audio: None, - codec_delay_ns: 0, - seek_pre_roll_ns: 0, - } - } -} - -/// Muxer options. -#[derive(Clone, Debug)] -pub struct MuxOptions { - /// `webm` or `matroska`. - pub doc_type: String, - /// Nanoseconds per tick (default 1 000 000). - pub timestamp_scale: u64, - /// Start a new cluster at the next video keyframe (or any frame without video) after this long. - pub cluster_duration_ns: u64, - pub lacing: LacingMode, - /// Maximum frames per laced block. - pub max_lace_frames: usize, - pub title: Option, - pub writing_app: String, -} - -impl Default for MuxOptions { - fn default() -> Self { - Self { - doc_type: "matroska".into(), - timestamp_scale: 1_000_000, - cluster_duration_ns: 1_000_000_000, - lacing: LacingMode::None, - max_lace_frames: 8, - title: None, - writing_app: "filmcraft".into(), - } - } -} - -pub(crate) fn el(out: &mut Vec, id: u32, data: &[u8]) { - write_id(out, id); - write_size(out, data.len() as u64, 1); - out.extend_from_slice(data); -} - -pub(crate) fn el_uint(out: &mut Vec, id: u32, v: u64) { - let n = (8 - v.leading_zeros() as usize / 8).max(1); - el(out, id, &v.to_be_bytes()[8 - n..]); -} - -pub(crate) fn el_float(out: &mut Vec, id: u32, v: f64) { - el(out, id, &v.to_be_bytes()); -} - -pub(crate) fn el_str(out: &mut Vec, id: u32, s: &str) { - el(out, id, s.as_bytes()); -} - -fn void(out: &mut Vec, total: usize) { - debug_assert!(total >= 2); - let sl = if total - 2 <= 126 { 1 } else { 8 }; - write_id(out, VOID); - write_size(out, (total - 1 - sl) as u64, sl); - out.resize(out.len() + total - 1 - sl, 0); -} - -#[derive(Clone, Debug)] -struct Pending { - track: usize, - ts: i64, - frames: Vec>, -} - -const SEEK_HEAD_SPACE: usize = 128; - -/// Streaming Matroska/WebM writer. -pub struct MkvWriter { - w: W, - opts: MuxOptions, - tracks: Vec, - segment_size_pos: u64, - segment_data_start: u64, - seek_head_pos: u64, - duration_pos: u64, - info_pos: u64, - tracks_pos: u64, - cluster: Vec, - cluster_ts: Option, - cluster_start_ns: i64, - pending: Option, - cues: Vec<(u64, u64, u64)>, // (time ticks, track number, cluster position) - cued_tracks_in_cluster: Vec, - end_ns: i64, - has_video: bool, -} - -impl MkvWriter { - pub fn new(mut w: W, tracks: Vec, opts: MuxOptions) -> Result { - if tracks.is_empty() || opts.timestamp_scale == 0 { - return Err(Error::Invalid("muxer needs at least one track and a non-zero timestamp scale".into())); - } - let mut out = Vec::new(); - let mut h = Vec::new(); - el_uint(&mut h, EBML_VERSION, 1); - el_uint(&mut h, EBML_READ_VERSION, 1); - el_uint(&mut h, EBML_MAX_ID_LENGTH, 4); - el_uint(&mut h, EBML_MAX_SIZE_LENGTH, 8); - el_str(&mut h, DOC_TYPE, &opts.doc_type); - el_uint(&mut h, DOC_TYPE_VERSION, 4); - el_uint(&mut h, DOC_TYPE_READ_VERSION, 2); - el(&mut out, EBML, &h); - write_id(&mut out, SEGMENT); - let base = w.stream_position()?; - let segment_size_pos = base + out.len() as u64; - write_unknown_size(&mut out, 8); - let segment_data_start = base + out.len() as u64; - let seek_head_pos = segment_data_start; - void(&mut out, SEEK_HEAD_SPACE); - // Info - let info_pos = base + out.len() as u64; - let mut info = Vec::new(); - el_uint(&mut info, TIMESTAMP_SCALE, opts.timestamp_scale); - el_str(&mut info, MUXING_APP, "filmcraft-matroska"); - el_str(&mut info, WRITING_APP, &opts.writing_app); - if let Some(t) = &opts.title { - el_str(&mut info, TITLE, t); - } - let dur_off_in_info = info.len() + 2 + 1; // ID (2) + size (1) - el_float(&mut info, DURATION, 0.0); - write_id(&mut out, INFO); - write_size(&mut out, info.len() as u64, 1); - let duration_pos = base + out.len() as u64 + dur_off_in_info as u64; - out.extend_from_slice(&info); - // Tracks - let tracks_pos = base + out.len() as u64; - let mut tr = Vec::new(); - for (i, t) in tracks.iter().enumerate() { - let mut e = Vec::new(); - el_uint(&mut e, TRACK_NUMBER, i as u64 + 1); - el_uint(&mut e, TRACK_UID, 0x1000 + i as u64 + 1); - let ty = match t.kind { - TrackKind::Video => 1, - TrackKind::Audio => 2, - TrackKind::Subtitle => 0x11, - TrackKind::Complex => 3, - TrackKind::Logo => 0x10, - TrackKind::Buttons => 0x12, - TrackKind::Control => 0x20, - TrackKind::Metadata => 0x21, - TrackKind::Other(n) => n as u64, - }; - el_uint(&mut e, TRACK_TYPE, ty); - el_uint(&mut e, FLAG_LACING, (t.kind == TrackKind::Audio && opts.lacing != LacingMode::None) as u64); - if let Some(l) = &t.language { - el_str(&mut e, LANGUAGE, l); - } - if let Some(n) = &t.name { - el_str(&mut e, NAME, n); - } - el_str(&mut e, CODEC_ID, &t.codec_id); - if !t.codec_private.is_empty() { - el(&mut e, CODEC_PRIVATE, &t.codec_private); - } - if let Some(d) = t.default_duration_ns { - el_uint(&mut e, DEFAULT_DURATION, d); - } - if t.codec_delay_ns > 0 { - el_uint(&mut e, CODEC_DELAY, t.codec_delay_ns); - } - if t.seek_pre_roll_ns > 0 { - el_uint(&mut e, SEEK_PRE_ROLL, t.seek_pre_roll_ns); - } - if let Some((pw, ph)) = t.video_size { - let mut v = Vec::new(); - el_uint(&mut v, PIXEL_WIDTH, pw as u64); - el_uint(&mut v, PIXEL_HEIGHT, ph as u64); - el(&mut e, VIDEO, &v); - } - if let Some((rate, ch, bits)) = t.audio { - let mut a = Vec::new(); - el_float(&mut a, SAMPLING_FREQUENCY, rate); - el_uint(&mut a, CHANNELS, ch); - if let Some(b) = bits { - el_uint(&mut a, BIT_DEPTH, b); - } - el(&mut e, AUDIO, &a); - } - el(&mut tr, TRACK_ENTRY, &e); - } - el(&mut out, TRACKS, &tr); - w.write_all(&out)?; - let has_video = tracks.iter().any(|t| t.kind == TrackKind::Video); - let n = tracks.len(); - Ok(Self { - w, - opts, - tracks, - segment_size_pos, - segment_data_start, - seek_head_pos, - duration_pos, - info_pos, - tracks_pos, - cluster: Vec::new(), - cluster_ts: None, - cluster_start_ns: 0, - pending: None, - cues: Vec::new(), - cued_tracks_in_cluster: vec![false; n], - end_ns: 0, - has_video, - }) - } - - /// Add one frame. `pts_ns` is the presentation time (codec delay is added back for storage); - /// frames must be supplied in the desired file order. `duration_ns` forces a BlockGroup with a - /// BlockDuration when it differs from the track's DefaultDuration. - pub fn write_frame(&mut self, track: usize, pts_ns: i64, keyframe: bool, data: &[u8], duration_ns: Option) -> Result<()> { - let spec = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - // the Matroska ProRes mapping stores frames without the 8-byte `size`+`icpf` header - let data = if spec.codec_id == "V_PRORES" && data.get(4..8) == Some(b"icpf") { &data[8..] } else { data }; - let scale = self.opts.timestamp_scale as i64; - let stored_ns = pts_ns + spec.codec_delay_ns as i64; - if stored_ns < 0 { - return Err(Error::Invalid("negative timestamp".into())); - } - let ts = (stored_ns + scale / 2) / scale; - let dur_end = pts_ns + duration_ns.or(spec.default_duration_ns).unwrap_or(0) as i64; - self.end_ns = self.end_ns.max(dur_end).max(pts_ns); - let is_video = spec.kind == TrackKind::Video; - let lacing = spec.kind == TrackKind::Audio && self.opts.lacing != LacingMode::None; - let explicit_dur = duration_ns.filter(|&d| Some(d) != spec.default_duration_ns); - // new cluster? - let start_new = match self.cluster_ts { - None => true, - Some(cts) => { - let long = stored_ns - self.cluster_start_ns >= self.opts.cluster_duration_ns as i64; - (long && (!self.has_video || (is_video && keyframe))) || ts - cts > 30_000 || ts < cts - } - }; - if start_new { - self.flush_cluster()?; - self.cluster_ts = Some(ts); - self.cluster_start_ns = stored_ns; - el_uint(&mut self.cluster, TIMESTAMP, ts as u64); - } - let cts = self.cluster_ts.unwrap_or(0); - // cue: video keyframes, or the first frame of each track per cluster when there is no video - if (is_video && keyframe) || (!self.has_video && !self.cued_tracks_in_cluster[track]) { - self.cued_tracks_in_cluster[track] = true; - let pos = self.w.stream_position()? - self.segment_data_start; - if self.cues.last().is_none_or(|c| c.2 != pos || c.1 != track as u64 + 1) { - self.cues.push((ts as u64, track as u64 + 1, pos)); - } - } - if lacing && explicit_dur.is_none() { - let same = self.pending.as_ref().is_some_and(|p| p.track == track && p.frames.len() < self.opts.max_lace_frames); - if !same { - self.flush_pending(); - } - match &mut self.pending { - Some(p) => p.frames.push(data.to_vec()), - None => self.pending = Some(Pending { track, ts: ts - cts, frames: vec![data.to_vec()] }), - } - return Ok(()); - } - self.flush_pending(); - let mut block = Vec::new(); - write_size(&mut block, track as u64 + 1, 1); - block.extend_from_slice(&((ts - cts) as i16).to_be_bytes()); - if let Some(d) = explicit_dur { - block.push(0); - block.extend_from_slice(data); - let mut g = Vec::new(); - el(&mut g, BLOCK, &block); - el_uint(&mut g, BLOCK_DURATION, (d as i64 / scale) as u64); - if !keyframe { - el(&mut g, REFERENCE_BLOCK, &[0xFF]); - } - el(&mut self.cluster, BLOCK_GROUP, &g); - } else { - block.push(if keyframe { 0x80 } else { 0 }); - block.extend_from_slice(data); - el(&mut self.cluster, SIMPLE_BLOCK, &block); - } - Ok(()) - } - - fn flush_pending(&mut self) { - let Some(p) = self.pending.take() else { return }; - let mut block = Vec::new(); - write_size(&mut block, p.track as u64 + 1, 1); - block.extend_from_slice(&(p.ts as i16).to_be_bytes()); - let n = p.frames.len(); - let mode = if n == 1 { LacingMode::None } else { self.opts.lacing }; - let fixed = p.frames.iter().all(|f| f.len() == p.frames[0].len()); - match mode { - LacingMode::None => block.push(0x80), - LacingMode::Xiph => { - block.push(0x80 | 0x02); - block.push((n - 1) as u8); - for f in &p.frames[..n - 1] { - let mut s = f.len(); - while s >= 255 { - block.push(255); - s -= 255; - } - block.push(s as u8); - } - } - LacingMode::FixedOrEbml if fixed => { - block.push(0x80 | 0x04); - block.push((n - 1) as u8); - } - LacingMode::Ebml | LacingMode::FixedOrEbml => { - block.push(0x80 | 0x06); - block.push((n - 1) as u8); - write_size(&mut block, p.frames[0].len() as u64, 1); - for i in 1..n - 1 { - let d = p.frames[i].len() as i64 - p.frames[i - 1].len() as i64; - // signed VINT: smallest length whose range holds d - let mut len = 1; - while len < 8 && d.unsigned_abs() >= (1u64 << (7 * len - 1)) - 1 { - len += 1; - } - let v = (d + (1i64 << (7 * len - 1)) - 1) as u64 | (1u64 << (7 * len)); - block.extend_from_slice(&v.to_be_bytes()[8 - len..]); - } - } - } - for f in &p.frames { - block.extend_from_slice(f); - } - el(&mut self.cluster, SIMPLE_BLOCK, &block); - } - - fn flush_cluster(&mut self) -> Result<()> { - self.flush_pending(); - if self.cluster_ts.is_some() { - let mut out = Vec::new(); - el(&mut out, CLUSTER, &self.cluster); - self.w.write_all(&out)?; - } - self.cluster.clear(); - self.cluster_ts = None; - self.cued_tracks_in_cluster.iter_mut().for_each(|c| *c = false); - Ok(()) - } - - /// Write Cues, back-patch SeekHead / Segment size / Duration, and return the writer. - pub fn finish(mut self) -> Result { - self.flush_cluster()?; - let cues_pos = self.w.stream_position()?; - let mut cues = Vec::new(); - for &(time, track, pos) in &self.cues { - let mut ctp = Vec::new(); - el_uint(&mut ctp, CUE_TRACK, track); - el_uint(&mut ctp, CUE_CLUSTER_POSITION, pos); - let mut cp = Vec::new(); - el_uint(&mut cp, CUE_TIME, time); - el(&mut cp, CUE_TRACK_POSITIONS, &ctp); - el(&mut cues, CUE_POINT, &cp); - } - let has_cues = !cues.is_empty(); - if has_cues { - let mut out = Vec::new(); - el(&mut out, CUES, &cues); - self.w.write_all(&out)?; - } - let end = self.w.stream_position()?; - // SeekHead - let mut sh = Vec::new(); - let mut entries = vec![(INFO, self.info_pos), (TRACKS, self.tracks_pos)]; - if has_cues { - entries.push((CUES, cues_pos)); - } - for (id, pos) in entries { - let mut s = Vec::new(); - let mut idb = Vec::new(); - write_id(&mut idb, id); - el(&mut s, SEEK_ID, &idb); - el_uint(&mut s, SEEK_POSITION, pos - self.segment_data_start); - el(&mut sh, SEEK, &s); - } - let mut out = Vec::new(); - el(&mut out, SEEK_HEAD, &sh); - if out.len() + 2 > SEEK_HEAD_SPACE { - return Err(Error::Invalid("SeekHead does not fit its reserved space".into())); - } - let rest = SEEK_HEAD_SPACE - out.len(); - void(&mut out, rest); - self.w.seek(SeekFrom::Start(self.seek_head_pos))?; - self.w.write_all(&out)?; - // Duration - let dur = self.end_ns as f64 / self.opts.timestamp_scale as f64; - self.w.seek(SeekFrom::Start(self.duration_pos))?; - self.w.write_all(&dur.to_be_bytes())?; - // Segment size - let mut sz = Vec::new(); - write_size(&mut sz, end - self.segment_data_start, 8); - self.w.seek(SeekFrom::Start(self.segment_size_pos))?; - self.w.write_all(&sz)?; - self.w.seek(SeekFrom::Start(end))?; - self.w.flush()?; - Ok(self.w) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Demuxer, TrackKind}; - use std::io::Cursor; - - fn roundtrip(lacing: LacingMode) { - let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG"); - v.video_size = Some((16, 16)); - v.default_duration_ns = Some(40_000_000); - let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT"); - a.audio = Some((48000.0, 1, Some(16))); - a.default_duration_ns = Some(10_000_000); - let mut s = TrackSpec::new(TrackKind::Subtitle, "S_TEXT/UTF8"); - s.language = Some("fre".into()); - let opts = MuxOptions { lacing, ..Default::default() }; - let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![v, a, s], opts).unwrap(); - let mut expect = Vec::new(); - for i in 0..50i64 { - let vf = vec![i as u8; 100 + i as usize]; - m.write_frame(0, i * 40_000_000, i % 10 == 0, &vf, None).unwrap(); - expect.push((0usize, i * 40, vf, i % 10 == 0)); - for k in 0..4 { - let n = if lacing == LacingMode::FixedOrEbml { 960 } else { 900 + (i as usize * 7 + k * 13) % 300 }; - let af = vec![(i * 4 + k as i64) as u8; n]; - let t = i * 40 + k as i64 * 10; - m.write_frame(1, t * 1_000_000, true, &af, None).unwrap(); - expect.push((1, t, af, true)); - } - if i % 20 == 5 { - m.write_frame(2, i * 40_000_000, true, b"hello", Some(500_000_000)).unwrap(); - expect.push((2, i * 40, b"hello".to_vec(), true)); - } - } - let bytes = m.finish().unwrap().into_inner(); - let mut d = Demuxer::from_slice(&bytes).unwrap(); - assert!(d.file().warnings.is_empty(), "{:?}", d.file().warnings); - assert_eq!(d.tracks()[2].language, "fre"); - assert!(!d.file().cues.is_empty()); - assert_eq!(d.duration_ns(), Some(2_300_000_000)); - let got: Vec<_> = d.by_ref().map(|p| p.unwrap()).collect(); - assert_eq!(got.len(), expect.len()); - for (g, e) in got.iter().zip(&expect) { - assert_eq!((g.track, g.pts, &g.data, g.keyframe), (e.0, e.1, &e.2, e.3)); - } - let sub = got.iter().find(|p| p.track == 2).unwrap(); - assert_eq!(sub.duration, 500); - // the index agrees with the packet stream - let f = d.file(); - for (ti, t) in f.tracks.iter().enumerate() { - let pk: Vec<_> = got.iter().filter(|p| p.track == ti).collect(); - assert_eq!(t.samples.len(), pk.len()); - for (i, p) in pk.iter().enumerate() { - assert_eq!(f.read_sample(&bytes[..], ti, i).unwrap(), p.data); - assert_eq!(t.samples[i].pts, p.pts); - } - } - // seeking - let sp = d.seek(0, 1_230_000_000).unwrap(); - assert_eq!(sp.pts, 1200); - let p = d.next_packet().unwrap().unwrap(); - assert_eq!((p.track, p.pts, p.keyframe), (0, 1200, true)); - let sp = d.seek(1, 1_235_000_000).unwrap(); - assert_eq!(sp.pts, 1230); - let p = d.next_packet().unwrap().unwrap(); - assert_eq!((p.track, p.pts), (1, 1230)); - } - - #[test] - fn roundtrip_unlaced() { - roundtrip(LacingMode::None); - } - #[test] - fn roundtrip_xiph() { - roundtrip(LacingMode::Xiph); - } - #[test] - fn roundtrip_ebml() { - roundtrip(LacingMode::Ebml); - } - #[test] - fn roundtrip_fixed() { - roundtrip(LacingMode::FixedOrEbml); - } -} diff --git a/crates/matroska/src/source.rs b/crates/matroska/src/source.rs deleted file mode 100644 index da10d89ff..000000000 --- a/crates/matroska/src/source.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Random-access byte sources for the demuxer. - -use std::cell::RefCell; -use std::io::{self, Read, Seek, SeekFrom}; -use std::sync::Arc; - -/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). -/// -/// Same shape as `filmcraft_isobmff::ByteSource`, so one adapter can serve both demuxers. -pub trait ByteSource { - /// Total length in bytes. - fn len(&self) -> u64; - /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - /// True if the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -fn eof() -> io::Error { - io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") -} - -impl ByteSource for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let start = usize::try_from(offset).map_err(|_| eof())?; - let end = start.checked_add(buf.len()).ok_or_else(eof)?; - let src = self.get(start..end).ok_or_else(eof)?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ByteSource for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ByteSource for &T { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Arc { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Box { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl ByteSource for std::fs::File { - fn len(&self) -> u64 { - self.metadata().map(|m| m.len()).unwrap_or(0) - } - #[cfg(unix)] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) - } - #[cfg(windows)] - fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { - use std::os::windows::fs::FileExt; - while !buf.is_empty() { - let n = self.seek_read(buf, offset)?; - if n == 0 { - return Err(eof()); - } - offset += n as u64; - buf = &mut buf[n..]; - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let mut f = self; - f.seek(SeekFrom::Start(offset))?; - f.read_exact(buf) - } -} - -/// Adapts any `Read + Seek` (e.g. `BufReader`, `Cursor>`) into a [`ByteSource`]. -/// -/// The length is taken once at construction; the stream position is tracked so sequential reads -/// do not issue redundant seeks. -pub struct ReadSeekSource { - inner: RefCell<(R, u64)>, - len: u64, -} - -impl ReadSeekSource { - /// Wrap a reader; seeks to the end once to learn the length. - pub fn new(mut r: R) -> io::Result { - let len = r.seek(SeekFrom::End(0))?; - Ok(Self { inner: RefCell::new((r, len)), len }) - } - /// Unwrap the reader. - pub fn into_inner(self) -> R { - self.inner.into_inner().0 - } -} - -impl ByteSource for ReadSeekSource { - fn len(&self) -> u64 { - self.len - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - if offset.checked_add(buf.len() as u64).is_none_or(|e| e > self.len) { - return Err(eof()); - } - let mut g = self.inner.borrow_mut(); - let (r, pos) = &mut *g; - if *pos != offset { - r.seek(SeekFrom::Start(offset))?; - *pos = offset; - } - let res = r.read_exact(buf); - match res { - Ok(()) => { - *pos = offset + buf.len() as u64; - Ok(()) - } - Err(e) => { - *pos = u64::MAX; // unknown; force a seek next time - Err(e) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_and_reader_sources() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(1, &mut b).unwrap(); - assert_eq!(b, [2, 3]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let r = ReadSeekSource::new(io::Cursor::new(v.clone())).unwrap(); - assert_eq!(r.len(), 4); - r.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - r.read_at(0, &mut b).unwrap(); - assert_eq!(b, [1, 2]); - assert!(r.read_at(3, &mut b).is_err()); - } -} diff --git a/crates/matroska/src/synthetic_tests.rs b/crates/matroska/src/synthetic_tests.rs deleted file mode 100644 index f10df3ba8..000000000 --- a/crates/matroska/src/synthetic_tests.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Hand-built files exercising features FFmpeg does not write: unknown-size Segment/Clusters, -//! header stripping, BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32. - -use crate::codec::Codec; -use crate::ebml::{self, write_id, write_unknown_size}; -use crate::ids::*; -use crate::mux::{el, el_float, el_str, el_uint}; -use crate::track::ContentEncoding; -use crate::{Demuxer, OpenOptions, Packet, open_with}; - -fn block(track: u8, rel: i16, flags: u8, data: &[u8]) -> Vec { - let mut b = vec![0x80 | track]; - b.extend_from_slice(&rel.to_be_bytes()); - b.push(flags); - b.extend_from_slice(data); - b -} - -fn synthetic() -> Vec { - let mut f = Vec::new(); - let mut h = Vec::new(); - el_str(&mut h, DOC_TYPE, "matroska"); - el_uint(&mut h, DOC_TYPE_VERSION, 4); - el(&mut f, EBML, &h); - write_id(&mut f, SEGMENT); - write_unknown_size(&mut f, 8); - // Info with a correct CRC-32 and a Void - let mut info = Vec::new(); - el_uint(&mut info, TIMESTAMP_SCALE, 1_000_000); - el_str(&mut info, TITLE, "Synthetic"); - el(&mut info, VOID, &[0; 3]); - let mut crc_info = Vec::new(); - el(&mut crc_info, CRC32, &ebml::crc32(&info).to_le_bytes()); - crc_info.extend_from_slice(&info); - el(&mut f, INFO, &crc_info); - // Tracks - let mut v = Vec::new(); - el_uint(&mut v, TRACK_NUMBER, 1); - el_uint(&mut v, TRACK_UID, 11); - el_uint(&mut v, TRACK_TYPE, 1); - el_str(&mut v, CODEC_ID, "V_MPEG4/ISO/AVC"); - el(&mut v, CODEC_PRIVATE, &[1, 0x64, 0, 0x1F]); - el_uint(&mut v, DEFAULT_DURATION, 40_000_000); - let mut mm = Vec::new(); - for (id, val) in [(PRIMARY_R_X, 0.708), (PRIMARY_R_Y, 0.292), (PRIMARY_G_X, 0.17), (PRIMARY_G_Y, 0.797), (PRIMARY_B_X, 0.131), (PRIMARY_B_Y, 0.046)] { - el_float(&mut mm, id, val); - } - el_float(&mut mm, WHITE_POINT_X, 0.3127); - el_float(&mut mm, WHITE_POINT_Y, 0.329); - el_float(&mut mm, LUMINANCE_MAX, 1000.0); - el_float(&mut mm, LUMINANCE_MIN, 0.0001); - let mut col = Vec::new(); - el_uint(&mut col, MATRIX_COEFFICIENTS, 9); - el_uint(&mut col, RANGE, 2); - el_uint(&mut col, TRANSFER_CHARACTERISTICS, 16); - el_uint(&mut col, PRIMARIES, 9); - el_uint(&mut col, MAX_CLL, 1000); - el_uint(&mut col, MAX_FALL, 400); - el(&mut col, MASTERING_METADATA, &mm); - let mut vid = Vec::new(); - el_uint(&mut vid, PIXEL_WIDTH, 1440); - el_uint(&mut vid, PIXEL_HEIGHT, 1080); - el_uint(&mut vid, DISPLAY_WIDTH, 1920); - el_uint(&mut vid, DISPLAY_HEIGHT, 1080); - el(&mut vid, COLOUR, &col); - // rectangular, a quarter turn clockwise (portrait phone video) - let mut proj = Vec::new(); - el_uint(&mut proj, PROJECTION_TYPE, 0); - el_float(&mut proj, PROJECTION_POSE_ROLL, -90.0); - el(&mut vid, PROJECTION, &proj); - el(&mut v, VIDEO, &vid); - // header stripping: every frame starts with 00 00 00 01 - let mut comp = Vec::new(); - el_uint(&mut comp, CONTENT_COMP_ALGO, 3); - el(&mut comp, CONTENT_COMP_SETTINGS, &[0, 0, 0, 1]); - let mut enc = Vec::new(); - el(&mut enc, CONTENT_COMPRESSION, &comp); - let mut encs = Vec::new(); - el(&mut encs, CONTENT_ENCODING, &enc); - el(&mut v, CONTENT_ENCODINGS, &encs); - let mut a = Vec::new(); - el_uint(&mut a, TRACK_NUMBER, 2); - el_uint(&mut a, TRACK_TYPE, 2); - el_str(&mut a, CODEC_ID, "A_AAC/MPEG4/LC/SBR"); - el_str(&mut a, LANGUAGE, "ger"); - let mut au = Vec::new(); - el_float(&mut au, SAMPLING_FREQUENCY, 24000.0); - el_float(&mut au, OUTPUT_SAMPLING_FREQUENCY, 48000.0); - el_uint(&mut au, CHANNELS, 2); - el(&mut a, AUDIO, &au); - let mut tr = Vec::new(); - el(&mut tr, TRACK_ENTRY, &v); - el(&mut tr, TRACK_ENTRY, &a); - el(&mut f, TRACKS, &tr); - // Chapters - let mut disp = Vec::new(); - el_str(&mut disp, CHAP_STRING, "Intro"); - let mut atom = Vec::new(); - el_uint(&mut atom, CHAPTER_UID, 5); - el_uint(&mut atom, CHAPTER_TIME_START, 0); - el_uint(&mut atom, CHAPTER_TIME_END, 40_000_000); - el(&mut atom, CHAPTER_DISPLAY, &disp); - let mut ed = Vec::new(); - el(&mut ed, CHAPTER_ATOM, &atom); - let mut ch = Vec::new(); - el(&mut ch, EDITION_ENTRY, &ed); - el(&mut f, CHAPTERS, &ch); - // Attachments - let mut af = Vec::new(); - el_str(&mut af, FILE_NAME, "font.ttf"); - el_str(&mut af, FILE_MEDIA_TYPE, "font/ttf"); - el_uint(&mut af, FILE_UID, 77); - el(&mut af, FILE_DATA, &[9; 100]); - let mut att = Vec::new(); - el(&mut att, ATTACHED_FILE, &af); - el(&mut f, ATTACHMENTS, &att); - // Cluster 1 (unknown size): keyframe SimpleBlock, BlockGroup with ReferenceBlock + duration - write_id(&mut f, CLUSTER); - write_unknown_size(&mut f, 1); - el_uint(&mut f, TIMESTAMP, 1000); - el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 1, 2])); - el(&mut f, SIMPLE_BLOCK, &block(2, 5, 0x80, &[0xAA; 10])); - let mut g = Vec::new(); - el(&mut g, BLOCK, &block(1, 40, 0, &[0x41, 3])); - el(&mut g, REFERENCE_BLOCK, &[0xD8]); - el_uint(&mut g, BLOCK_DURATION, 80); - el(&mut f, BLOCK_GROUP, &g); - el(&mut f, SIMPLE_BLOCK, &block(9, 0, 0x80, &[1])); // unknown track: ignored - // Cluster 2 (unknown size), ended by Tags - write_id(&mut f, CLUSTER); - write_unknown_size(&mut f, 8); - el_uint(&mut f, TIMESTAMP, 1120); - el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 4])); - let mut st = Vec::new(); - el_str(&mut st, TAG_NAME, "ARTIST"); - el_str(&mut st, TAG_STRING, "Nobody"); - let mut tg = Vec::new(); - let mut tgt = Vec::new(); - el_uint(&mut tgt, TAG_TRACK_UID, 11); - el(&mut tg, TARGETS, &tgt); - el(&mut tg, SIMPLE_TAG, &st); - let mut tags = Vec::new(); - el(&mut tags, TAG, &tg); - el(&mut f, TAGS, &tags); - f -} - -#[test] -fn synthetic_file() { - let bytes = synthetic(); - let f = open_with(&bytes[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); - assert!(f.live); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - assert_eq!((f.crc_checked, f.crc_errors.len()), (1, 0)); - assert_eq!(f.info.title.as_deref(), Some("Synthetic")); - let v = &f.tracks[0]; - assert!(matches!(&v.codec, Codec::Avc { avcc } if avcc[1] == 0x64)); - let vi = v.video.as_ref().unwrap(); - assert_eq!(vi.pixel_aspect(), (4, 3)); - assert_eq!(vi.projection.as_ref().map(|p| p.roll), Some(-90.0)); - assert_eq!(vi.display_rotation(), Some(1)); - let c = vi.colour.as_ref().unwrap(); - assert!(c.full_range()); - assert_eq!((c.matrix_coefficients, c.transfer_characteristics, c.primaries, c.max_cll, c.max_fall), (Some(9), Some(16), Some(9), Some(1000), Some(400))); - let m = c.mastering.as_ref().unwrap(); - assert_eq!(m.primaries[1], (0.17, 0.797)); - assert_eq!(m.white_point, (0.3127, 0.329)); - assert_eq!((m.luminance_max, m.luminance_min), (1000.0, 0.0001)); - assert_eq!(v.encodings, vec![ContentEncoding::HeaderStripping { bytes: vec![0, 0, 0, 1], scope: 1 }]); - // legacy AAC ID: ASC synthesised with explicit SBR signalling (LC, 24 kHz, stereo → 48 kHz) - assert_eq!(f.tracks[1].codec, Codec::Aac { asc: vec![0x13, 0x10, 0x56, 0xE5, 0x98] }); - assert_eq!(f.tracks[1].language, "ger"); - assert_eq!(f.chapters[0].chapters[0].title(), Some("Intro")); - assert_eq!(f.chapters[0].chapters[0].end_ns, Some(40_000_000)); - assert_eq!(f.attachments.len(), 1); - let at = &f.attachments[0]; - assert_eq!((at.name.as_str(), at.media_type.as_str(), at.uid), ("font.ttf", "font/ttf", 77)); - assert_eq!(&bytes[at.data_offset as usize..][..100], &[9; 100]); - assert_eq!(f.tags[0].targets.track_uids, vec![11]); - assert_eq!(f.tags[0].simple[0].string.as_deref(), Some("Nobody")); - assert_eq!(f.clusters.len(), 2); - assert_eq!(v.samples.len(), 3); - assert_eq!(v.keyframes(), &[0, 2]); - - let d = Demuxer::from_slice(&bytes).unwrap(); - let p: Vec = d.map(|p| p.unwrap()).collect(); - assert_eq!(p.len(), 4); - assert_eq!((p[0].track, p[0].pts, p[0].keyframe, p[0].duration), (0, 1000, true, 40)); - assert_eq!(p[0].data, [0, 0, 0, 1, 0x65, 1, 2]); - assert_eq!((p[1].track, p[1].pts, p[1].data.len()), (1, 1005, 10)); - assert_eq!((p[2].pts, p[2].keyframe, p[2].duration), (1040, false, 80)); - assert_eq!(p[2].data, [0, 0, 0, 1, 0x41, 3]); - assert_eq!((p[3].pts, p[3].pts_ns), (1120, 1_120_000_000)); - assert_eq!(f.read_sample(&bytes[..], 0, 1).unwrap(), p[2].data); - - // CRC mismatch is reported, not fatal - let mut bad = bytes.clone(); - let title = bad.windows(9).position(|w| w == b"Synthetic").unwrap(); - bad[title] = b's'; - let f = open_with(&bad[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); - assert_eq!(f.crc_errors.len(), 1); - - // seeking without index or cues builds the index - let mut d = Demuxer::with_options(&bytes[..], OpenOptions { index: false, verify_crc: false }).unwrap(); - assert_eq!(d.file().tracks.len(), 2); - assert_eq!(d.seek(0, 1_100_000_000).unwrap().pts, 1000); - assert_eq!(d.seek(0, 1_130_000_000).unwrap().pts, 1120); - assert_eq!(d.next_packet().unwrap().unwrap().pts, 1120); - assert!(d.file().indexed); -} diff --git a/crates/matroska/src/track.rs b/crates/matroska/src/track.rs deleted file mode 100644 index ecdecabe8..000000000 --- a/crates/matroska/src/track.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! Track descriptions (RFC 9559 §5.1.4). - -use crate::codec::Codec; - -/// Track type (`TrackType`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrackKind { - Video, - Audio, - Complex, - Logo, - Subtitle, - Buttons, - Control, - Metadata, - Other(u8), -} - -impl TrackKind { - pub(crate) fn from_type(t: u64) -> TrackKind { - match t { - 1 => TrackKind::Video, - 2 => TrackKind::Audio, - 3 => TrackKind::Complex, - 0x10 => TrackKind::Logo, - 0x11 => TrackKind::Subtitle, - 0x12 => TrackKind::Buttons, - 0x20 => TrackKind::Control, - 0x21 => TrackKind::Metadata, - n => TrackKind::Other(n as u8), - } - } -} - -/// SMPTE ST 2086 mastering display metadata (`MasteringMetadata`). Chromaticities are CIE 1931 xy, -/// luminance in cd/m². -#[derive(Clone, Debug, Default, PartialEq)] -pub struct MasteringMetadata { - /// Red, green, blue primaries as (x, y). - pub primaries: [(f64, f64); 3], - pub white_point: (f64, f64), - pub luminance_max: f64, - pub luminance_min: f64, -} - -/// `Colour` element. Code points use ITU-T H.273 values (as Matroska does). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Colour { - /// `MatrixCoefficients` (H.273; 2 = unspecified). - pub matrix_coefficients: Option, - pub bits_per_channel: Option, - pub chroma_subsampling_horz: Option, - pub chroma_subsampling_vert: Option, - pub cb_subsampling_horz: Option, - pub cb_subsampling_vert: Option, - pub chroma_siting_horz: Option, - pub chroma_siting_vert: Option, - /// `Range`: 0 unspecified, 1 broadcast (limited), 2 full, 3 defined by matrix/transfer. - pub range: Option, - /// `TransferCharacteristics` (H.273). - pub transfer_characteristics: Option, - /// `Primaries` (H.273). - pub primaries: Option, - /// Maximum content light level (cd/m²). - pub max_cll: Option, - /// Maximum frame-average light level (cd/m²). - pub max_fall: Option, - pub mastering: Option, -} - -impl Colour { - /// True if `Range` says full range. - pub fn full_range(&self) -> bool { - self.range == Some(2) - } -} - -/// `Video` element. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct VideoInfo { - pub pixel_width: u32, - pub pixel_height: u32, - /// `DisplayWidth`/`DisplayHeight`, in `display_unit` (0 = pixels, 3 = display aspect ratio). - pub display_width: Option, - pub display_height: Option, - pub display_unit: u64, - /// Crop (top, bottom, left, right) in pixels. - pub crop: (u32, u32, u32, u32), - /// `FlagInterlaced`: 0 undetermined, 1 interlaced, 2 progressive. - pub flag_interlaced: u64, - /// `FieldOrder` (0 progressive, 1 tff, 2 undetermined, 6 bff, 9/14 interleaved). - pub field_order: Option, - pub stereo_mode: Option, - /// `AlphaMode`: 1 if BlockAdditions carry alpha. - pub alpha_mode: u64, - /// `ColourSpace` FourCC (uncompressed video). - pub colour_space: Option<[u8; 4]>, - pub colour: Option, - pub projection: Option, -} - -/// `Projection` element (RFC 9559 §5.1.4.1.28.41): pose angles in degrees. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Projection { - /// 0 rectangular, 1 equirectangular, 2 cubemap, 3 mesh. - pub projection_type: u64, - /// Clockwise around the up vector. - pub yaw: f64, - /// Counter-clockwise around the right vector. - pub pitch: f64, - /// Counter-clockwise around the forward vector. - pub roll: f64, -} - -impl VideoInfo { - /// Clockwise quarter turns (0–3) to display a rectangular video, from `ProjectionPoseRoll` - /// (a counter-clockwise angle, so −90 is a quarter turn clockwise). None for other - /// projections, flips (non-zero yaw / pitch) or angles that are not a multiple of 90°. - pub fn display_rotation(&self) -> Option { - let Some(p) = &self.projection else { return Some(0) }; - if p.projection_type != 0 || p.yaw != 0.0 || p.pitch != 0.0 || p.roll % 90.0 != 0.0 { - return None; - } - Some((-(p.roll / 90.0) as i64).rem_euclid(4) as u8) - } - - /// Pixel aspect ratio derived from the display size (pixels or aspect-ratio units), reduced. - pub fn pixel_aspect(&self) -> (u32, u32) { - let pw = self.pixel_width.saturating_sub(self.crop.2.saturating_add(self.crop.3)) as u64; - let ph = self.pixel_height.saturating_sub(self.crop.0.saturating_add(self.crop.1)) as u64; - let (Some(dw), Some(dh)) = (self.display_width, self.display_height) else { return (1, 1) }; - if pw == 0 || ph == 0 || dw == 0 || dh == 0 || !matches!(self.display_unit, 0 | 3) { - return (1, 1); - } - // PAR = (dw/dh) / (pw/ph) - let (n, d) = (dw as u64 * ph, dh as u64 * pw); - let g = gcd(n, d); - ((n / g) as u32, (d / g) as u32) - } -} - -pub(crate) fn gcd(mut a: u64, mut b: u64) -> u64 { - while b != 0 { - (a, b) = (b, a % b); - } - a.max(1) -} - -/// `Audio` element. -#[derive(Clone, Debug, PartialEq)] -pub struct AudioInfo { - /// `SamplingFrequency` (Hz). - pub sampling_frequency: f64, - /// `OutputSamplingFrequency` (e.g. SBR output rate). - pub output_sampling_frequency: Option, - pub channels: u64, - pub bit_depth: Option, -} - -impl Default for AudioInfo { - fn default() -> Self { - Self { sampling_frequency: 8000.0, output_sampling_frequency: None, channels: 1, bit_depth: None } - } -} - -/// One `ContentEncoding`. -#[derive(Clone, Debug, PartialEq)] -pub enum ContentEncoding { - /// `ContentCompAlgo` 3: the given bytes were stripped from the start of every frame. - HeaderStripping { bytes: Vec, scope: u64 }, - /// Other compression (0 zlib, 1 bzlib, 2 lzo1x): not supported for reading frame data. - Compression { algo: u64, settings: Vec, scope: u64 }, - /// Encryption (not supported for reading frame data). - Encryption { scope: u64 }, -} - -/// One entry of a track's sample (frame) table. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Sample { - /// Byte offset of the (stored) frame data in the file. - pub offset: u64, - /// Stored size (excluding header-stripped bytes; see [`Track::sample_size`]). - pub size: u32, - /// Presentation timestamp in the track timebase (`TimestampScale` ticks). - pub pts: i64, - /// Duration in timebase ticks (from `BlockDuration` or `DefaultDuration`), 0 if unknown. - pub duration: u64, - pub keyframe: bool, - /// Index of the owning cluster in [`crate::MkvFile::clusters`]. - pub cluster: u32, - /// Offset of the block element (SimpleBlock / BlockGroup) header. - pub block_offset: u64, - /// Index of this frame within a laced block. - pub lace: u16, - /// (offset, size) of the block's `BlockAdditional` with `BlockAddID` 1, when it has one and - /// is not laced. In WebM this is the alpha layer of a VP8/VP9 track with `AlphaMode` 1: a - /// second, independently coded stream whose luma plane is the frame's alpha. - pub addition: Option<(u64, u32)>, -} - -/// A Matroska track (`TrackEntry`) plus, once indexed, its sample table. -#[derive(Clone, Debug)] -pub struct Track { - pub number: u64, - pub uid: u64, - pub kind: TrackKind, - pub enabled: bool, - pub default: bool, - pub forced: bool, - pub hearing_impaired: bool, - pub lacing: bool, - /// `DefaultDuration` in nanoseconds. - pub default_duration_ns: Option, - pub name: String, - /// `Language` (ISO 639-2, default `eng`). - pub language: String, - pub language_bcp47: Option, - pub codec_id: String, - pub codec_private: Vec, - pub codec_name: String, - pub codec: Codec, - /// `CodecDelay` in nanoseconds. - pub codec_delay_ns: u64, - /// `SeekPreRoll` in nanoseconds. - pub seek_pre_roll_ns: u64, - pub video: Option, - pub audio: Option, - pub encodings: Vec, - /// Timebase numerator/denominator in seconds: `TimestampScale / 1e9`, reduced. - pub timebase: (u64, u64), - /// Frame table (empty unless the file was opened with indexing). - pub samples: Vec, - pub(crate) sync: Vec, -} - -impl Track { - /// Bytes prepended to every frame by header-stripping compression, if any. - pub fn stripped_header(&self) -> Option<&[u8]> { - self.encodings.iter().find_map(|e| match e { - ContentEncoding::HeaderStripping { bytes, scope } if scope & 1 != 0 => Some(bytes.as_slice()), - _ => None, - }) - } - - /// True if frames can be returned verbatim or with header stripping undone. - pub fn frames_readable(&self) -> bool { - self.encodings.iter().all(|e| match e { - ContentEncoding::HeaderStripping { .. } => true, - ContentEncoding::Compression { scope, .. } | ContentEncoding::Encryption { scope } => scope & 1 == 0, - }) - } - - /// Bytes to put in front of a stored frame of `stored_size` bytes to get the codec frame: - /// header-stripped bytes, or for `V_PRORES` the 8-byte frame header (`size` + `icpf`) that the - /// Matroska ProRes mapping omits. - pub fn frame_prefix(&self, stored_size: u64) -> Vec { - let mut p = self.stripped_header().map(<[u8]>::to_vec).unwrap_or_default(); - if matches!(self.codec, Codec::ProRes { .. }) { - let total = stored_size + p.len() as u64 + 8; - let mut h = (total as u32).to_be_bytes().to_vec(); - h.extend_from_slice(b"icpf"); - h.extend_from_slice(&p); - p = h; - } - p - } - - /// Frame size of sample `i` as returned by `read_sample` / packets (stored size plus prefix). - pub fn sample_size(&self, i: usize) -> Option { - let s = self.samples.get(i)?; - Some(s.size as u64 + self.frame_prefix(s.size as u64).len() as u64) - } - - /// Indices of keyframe samples, ascending by file order. - pub fn keyframes(&self) -> &[u32] { - &self.sync - } - - /// Timebase ticks → nanoseconds. - pub fn ticks_to_ns(&self, t: i64) -> i64 { - (t as i128 * self.timebase.0 as i128 * 1_000_000_000 / self.timebase.1 as i128) as i64 - } - - /// Nanoseconds → timebase ticks (floor). - pub fn ns_to_ticks(&self, ns: i64) -> i64 { - let n = ns as i128 * self.timebase.1 as i128; - let d = self.timebase.0 as i128 * 1_000_000_000; - n.div_euclid(d) as i64 - } - - /// Index of the last sample with `pts <= t` (in file order), or the first sample. - pub fn sample_at_pts(&self, t: i64) -> Option { - if self.samples.is_empty() { - return None; - } - let mut best: Option = None; - for (i, s) in self.samples.iter().enumerate() { - if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) { - best = Some(i); - } - } - Some(best.unwrap_or(0)) - } - - /// Index of the keyframe with the greatest `pts <= t` (the first keyframe if none precedes `t`). - pub fn keyframe_before_pts(&self, t: i64) -> Option { - let mut best: Option = None; - for &k in &self.sync { - let s = &self.samples[k as usize]; - if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) { - best = Some(k as usize); - } - } - best.or_else(|| self.sync.first().map(|&k| k as usize)) - } - - /// Index of the nearest keyframe at or before sample `index` in file order. - pub fn sync_sample_before(&self, index: usize) -> usize { - match self.sync.binary_search(&(index as u32)) { - Ok(i) => self.sync[i] as usize, - Err(0) => 0, - Err(i) => self.sync[i - 1] as usize, - } - } -} - -#[cfg(test)] -mod rotation_tests { - use super::{Projection, VideoInfo}; - - fn with_roll(projection_type: u64, yaw: f64, pitch: f64, roll: f64) -> VideoInfo { - VideoInfo { projection: Some(Projection { projection_type, yaw, pitch, roll }), ..Default::default() } - } - - #[test] - fn display_rotation_maps_rectangular_roll_to_clockwise_quarter_turns() { - assert_eq!(VideoInfo::default().display_rotation(), Some(0), "no Projection: as stored"); - // ProjectionPoseRoll is counter-clockwise: -90 is a quarter turn clockwise - for (roll, turns) in [(0.0, 0), (-90.0, 1), (180.0, 2), (-180.0, 2), (90.0, 3), (-270.0, 3), (270.0, 1), (360.0, 0)] { - assert_eq!(with_roll(0, 0.0, 0.0, roll).display_rotation(), Some(turns), "roll {roll}"); - } - } - - #[test] - fn display_rotation_leaves_non_rectangular_flipped_and_odd_angles_alone() { - assert_eq!(with_roll(1, 0.0, 0.0, -90.0).display_rotation(), None, "equirectangular"); - assert_eq!(with_roll(0, 180.0, 0.0, 0.0).display_rotation(), None, "yaw flip"); - assert_eq!(with_roll(0, 0.0, 180.0, 0.0).display_rotation(), None, "pitch flip"); - assert_eq!(with_roll(0, 0.0, 0.0, 45.0).display_rotation(), None, "not a quarter turn"); - assert_eq!(with_roll(0, 0.0, 0.0, f64::NAN).display_rotation(), None, "malformed angle"); - } -} - -#[cfg(test)] -mod aspect_overflow_tests { - use super::VideoInfo; - - #[test] - fn hostile_crop_sums_do_not_overflow() { - let video = VideoInfo { - pixel_width: 640, - pixel_height: 480, - display_width: Some(640), - display_height: Some(480), - crop: (u32::MAX, 1, u32::MAX, 1), - ..Default::default() - }; - assert_eq!(video.pixel_aspect(), (1, 1)); - } - - #[test] - fn ordinary_pixel_aspect_is_unchanged() { - let video = VideoInfo { pixel_width: 1920, pixel_height: 1080, display_width: Some(1440), display_height: Some(1080), ..Default::default() }; - assert_eq!(video.pixel_aspect(), (3, 4)); - } -} diff --git a/crates/matroska/tests/oracle.rs b/crates/matroska/tests/oracle.rs index 5eaae0a03..f53b09978 100644 --- a/crates/matroska/tests/oracle.rs +++ b/crates/matroska/tests/oracle.rs @@ -58,8 +58,14 @@ fn compare(ffprobe: &Path, path: &Path) -> (MkvFile, Vec) { assert!((pts - p.pts).abs() <= p.lace as i64, "{ctx}: pts ffprobe {pts} vs {}", p.pts); pts_checked += 1; } + // The last audio or video frame of a track cut by `-t` is shorter than a codec frame. + // ffmpeg 8 writes a shortened BlockDuration there and reports the codec frame duration; a + // remux by our writer stores DefaultDuration and ffprobe reports the shortened one. So + // that frame's duration is not compared. A subtitle's duration is its cue's, and is. + let last_of_track = packets[i + 1..].iter().all(|q| q.track != p.track); if let Some(dur) = int(&f["duration"]) && p.duration != 0 + && (!last_of_track || file.tracks[p.track].kind == TrackKind::Subtitle) { assert!((dur - p.duration as i64).abs() <= 1, "{ctx}: duration ffprobe {dur} vs {}", p.duration); dur_checked += 1; @@ -226,11 +232,11 @@ fn live_stream_without_cues() { } #[test] -fn hevc_hdr_colour() { +fn hevc_hdr_color() { let Some((_, f, _)) = run("hevc_hdr.mkv") else { return }; let t = &f.tracks[0]; assert!(matches!(&t.codec, Codec::Hevc { hvcc } if hvcc.first() == Some(&1))); - let c = t.video.as_ref().unwrap().colour.as_ref().expect("Colour element"); + let c = t.video.as_ref().unwrap().color.as_ref().expect("Colour element"); assert_eq!(c.primaries, Some(9)); assert_eq!(c.transfer_characteristics, Some(16)); assert_eq!(c.matrix_coefficients, Some(9)); @@ -286,12 +292,14 @@ fn laced_remux() { let specs: Vec = file .tracks .iter() - .map(|t| { + .enumerate() + .map(|(ti, t)| { let mut s = TrackSpec::new(t.kind, t.codec_id.clone()); s.codec_private = t.codec_private.clone(); + // audio without a DefaultDuration gets its mean packet spacing, so it can lace s.default_duration_ns = t.default_duration_ns.or_else(|| { - // give AAC a DefaultDuration so every lace gets a timestamp - matches!(t.codec, Codec::Aac { .. }).then(|| (1024.0 * 1e9 / t.audio.as_ref().unwrap().sampling_frequency) as u64) + let pts: Vec = packets.iter().filter(|p| p.track == ti).map(|p| p.pts_ns).collect(); + (t.kind == TrackKind::Audio && pts.len() > 1).then(|| ((pts[pts.len() - 1] - pts[0]) / (pts.len() as i64 - 1)) as u64) }); s.video_size = t.video.as_ref().map(|v| (v.pixel_width, v.pixel_height)); s.audio = t.audio.as_ref().map(|a| (a.sampling_frequency, a.channels, a.bit_depth)); @@ -316,7 +324,7 @@ fn laced_remux() { assert_eq!(got.len(), packets.len()); for (a, b) in got.iter().zip(&packets) { assert_eq!(a.data, b.data, "{src} {mode:?}: payload"); - assert_eq!(a.track, b.track); + assert_eq!((a.track, a.pts), (b.track, b.pts), "{src} {mode:?}: timestamp"); } std::fs::remove_file(&outp).unwrap(); } diff --git a/crates/media/src/still/exr.rs b/crates/media/src/still/exr.rs index d478e3a04..bd0cdef74 100644 --- a/crates/media/src/still/exr.rs +++ b/crates/media/src/still/exr.rs @@ -27,6 +27,10 @@ const MAGIC: [u8; 4] = [0x76, 0x2f, 0x31, 0x01]; /// Largest picture we decode (each pixel takes 16 bytes as RGBA floats). const MAX_PIXELS: u64 = 1 << 26; +/// Maximum memory retained while decoding the data window and, when different, +/// the display window. Both buffers coexist during placement. +/// 2 GiB: an 8K RGBA f32 plate is 566 MB and the data and display windows may both be held. +const MAX_DECODED_BYTES: usize = 2 << 30; /// Most attributes / channels we read from a header. const MAX_ATTRIBUTES: usize = 4096; const MAX_CHANNELS: usize = 1024; @@ -418,6 +422,34 @@ pub fn decode(name: &str, b: &[u8]) -> Result { } let table = h.end; let px_len = dw.checked_mul(dh).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("size".into()))?; + let frame_len = fw.checked_mul(fh).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("display window size".into()))?; + let decoded_bytes = px_len + .checked_mul(std::mem::size_of::()) + .and_then(|n| if h.data == h.display { Some(n) } else { frame_len.checked_mul(std::mem::size_of::()).and_then(|m| n.checked_add(m)) }) + .ok_or_else(|| bad("decoded size".into()))?; + if decoded_bytes > MAX_DECODED_BYTES { + return Err(MediaError::Unsupported(format!("{name}: EXR decoded buffers exceed the {MAX_DECODED_BYTES}-byte limit"))); + } + let chunks = dh.div_ceil(lpc); + let table_end = table.checked_add(chunks.checked_mul(8).ok_or_else(|| bad("offset table size".into()))?).ok_or_else(|| bad("offset table size".into()))?; + if table_end > b.len() { + return Err(bad("offset table is truncated".into())); + } + for i in 0..chunks { + let at = table + i * 8; + let off = le32(b, at).zip(le32(b, at + 4)).map(|(lo, hi)| u64::from(lo) | u64::from(hi) << 32); + let off = off.and_then(|o| usize::try_from(o).ok()).ok_or_else(|| bad(format!("offset of chunk {i}")))?; + let size = le32(b, off.checked_add(4).ok_or_else(|| bad(format!("chunk {i}")))?).ok_or_else(|| bad(format!("chunk {i}")))? as usize; + let end = off.checked_add(8).and_then(|n| n.checked_add(size)).ok_or_else(|| bad(format!("chunk {i}")))?; + if end > b.len() { + return Err(bad(format!("chunk {i} is truncated"))); + } + let y = le32(b, off).map(|y| y as i32); + let want = i64::from(h.data.y0) + (i * lpc) as i64; + if y.map(i64::from) != Some(want) { + return Err(bad(format!("chunk {i} is not at line {want}"))); + } + } let mut px = vec![0f32; px_len]; if !alpha { px.par_chunks_mut(4).for_each(|p| { diff --git a/crates/media/tests/exr_oracle.rs b/crates/media/tests/it/exr_oracle.rs similarity index 100% rename from crates/media/tests/exr_oracle.rs rename to crates/media/tests/it/exr_oracle.rs diff --git a/crates/media/tests/hdr_oracle.rs b/crates/media/tests/it/hdr_oracle.rs similarity index 100% rename from crates/media/tests/hdr_oracle.rs rename to crates/media/tests/it/hdr_oracle.rs diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs new file mode 100644 index 000000000..43b1a5bcb --- /dev/null +++ b/crates/media/tests/it/main.rs @@ -0,0 +1,3 @@ +mod exr_oracle; +mod hdr_oracle; +mod tiff_ycbcr; diff --git a/crates/media/tests/tiff_ycbcr.rs b/crates/media/tests/it/tiff_ycbcr.rs similarity index 100% rename from crates/media/tests/tiff_ycbcr.rs rename to crates/media/tests/it/tiff_ycbcr.rs diff --git a/crates/mpeg2v/README.md b/crates/mpeg2v/README.md index 0562ffed3..2ccc86feb 100644 --- a/crates/mpeg2v/README.md +++ b/crates/mpeg2v/README.md @@ -38,6 +38,9 @@ parameters, so a random-access point without its own sequence header still decod - Main Profile at Low/Main/High-1440/High Level, 4:2:2 Profile (IMX / D-10, XDCAM HD422), Simple Profile, MPEG-1 (including D pictures). 4:4:4 is refused (no profile in use for it). +- Pictures above 2^25 luma samples (8192 x 4096; 8K UHD at 7680 x 4320 fits, and the largest MPEG-2 level, + High, is 1920 x 1152) are refused as `Unsupported`: the 14-bit header fields allow 16383 x 16383, which + would ask for gigabytes per frame from hostile input. - Frame pictures with frame and field motion compensation, dual prime, frame and field DCT; field pictures with field, 16x8 and dual-prime motion compensation, the second field of a P frame predicted from the first; open and closed GOPs; B pictures without a forward @@ -77,7 +80,7 @@ are compared within a tolerance: **per frame max |Δ| ≤ 4 and PSNR ≥ 58 dB, samples differing**. Frame count, display order, picture types, interlacing and field order are exact (ffprobe). -`tests/oracle.rs` (FFmpeg-encoded elementary streams): +`tests/it/oracle.rs` (FFmpeg-encoded elementary streams): | Fixture | Content | Max Δ | Differing | PSNR | |---|---|---|---|---| @@ -110,7 +113,7 @@ FFmpeg's integer IDCT saturates differently from the exact one. ## Performance -`cargo test --release -p filmcraft-mpeg2v --test perf -- --ignored --nocapture` decodes the +`cargo test --release -p filmcraft-mpeg2v --test it perf:: -- --ignored --nocapture` decodes the 1080i fixture (1920×1080 4:2:0, 25 Mb/s, IBBP, field MC/DCT; FFmpeg-encoded) on an M4 Pro (14 cores) that was heavily loaded by other jobs (load average ≈ 190), best of five runs: **271 fps on one thread, 274 fps with slice rows on rayon** (runs under heavier momentary load @@ -126,5 +129,5 @@ fell to 77–150 fps). These are lower bounds. rounding, field addressing, edge clamping. - `src/tests.rs`: a hand-built intra picture decodes to its DC; truncation at every byte and 3000 random mutations never panic. -- `src/synth_tests.rs`, `tests/oracle.rs`: see above (single-threaded decode identical to the +- `src/synth_tests.rs`, `tests/it/oracle.rs`: see above (single-threaded decode identical to the threaded one). diff --git a/crates/mpeg2v/src/lib.rs b/crates/mpeg2v/src/lib.rs index 261742c25..55c1ab9ad 100644 --- a/crates/mpeg2v/src/lib.rs +++ b/crates/mpeg2v/src/lib.rs @@ -163,6 +163,9 @@ struct Seq { /// MPEG-1 pel_aspect_ratio (height/width of a sample) ×10000, Table 2-D.2. const MPEG1_PEL_ASPECT: [u32; 15] = [0, 10000, 6735, 7031, 7615, 8055, 8437, 8935, 9157, 9815, 10255, 10695, 10950, 11575, 12015]; +/// Largest accepted luma area (8192 x 4096; 8K UHD is 7680 x 4320). +const MAX_PICTURE_AREA: u64 = 1 << 25; + impl Seq { fn new(hdr: SequenceHeader, ext: Option, display: Option) -> Result { let (w, h) = match &ext { @@ -175,7 +178,9 @@ impl Seq { 3 => ChromaFormat::Yuv444, _ => return Err(Error::Invalid("reserved chroma_format".into())), }; - if w > 16383 || h > 16383 { + // The 14-bit sizes allow 16383 x 16383 (gigabytes per frame, several frames and a gray reference + // held); no real stream exceeds 8K, so cap the area there. + if w > 16383 || h > 16383 || u64::from(w) * u64::from(h) > MAX_PICTURE_AREA { return Err(Error::Unsupported(format!("{w}x{h}"))); } let progressive_sequence = ext.is_none_or(|e| e.progressive_sequence); @@ -333,11 +338,16 @@ pub fn access_units(es: &[u8]) -> Vec> { } PICTURE_START => { // picture_structure from the picture coding extension that follows - let structure = codes[k + 1..] + let structure = codes .iter() - .take_while(|c| c.1 == EXTENSION || c.1 == headers::USER_DATA) - .find_map(|&(p, c)| { - let end = codes.iter().find(|x| x.0 > p).map_or(es.len(), |x| x.0); + .enumerate() + .skip(k + 1) + .take_while(|(_, c)| c.1 == EXTENSION || c.1 == headers::USER_DATA) + .find_map(|(next, &(p, c))| { + // `next` is this unit's position in `codes`, so its successor gives the + // payload end directly. Searching `codes` from the beginning here made a + // stream of short picture/extension pairs quadratic to index. + let end = codes.get(next + 1).map_or(es.len(), |x| x.0); (c == EXTENSION).then(|| Extension::parse(&es[(p + 4).min(end)..end]).ok()).flatten() }) .and_then(|e| match e { diff --git a/crates/mpeg2v/src/synth_tests.rs b/crates/mpeg2v/src/synth_tests.rs index 0d4dd5453..33baf11a8 100644 --- a/crates/mpeg2v/src/synth_tests.rs +++ b/crates/mpeg2v/src/synth_tests.rs @@ -725,7 +725,7 @@ fn check(cfg: Cfg) { } let psnr = 10.0 * (255f64 * 255.0 * n as f64 / sse.max(1e-9)).log10(); println!("{}: {} frames, max diff {worst}, {:.3}% differ, PSNR {psnr:.2} dB", cfg.name, pics.len(), 100.0 * differ as f64 / n as f64); - // the same criteria as the ffmpeg-encoded fixtures (tests/oracle.rs) + // the same criteria as the ffmpeg-encoded fixtures (tests/it/oracle.rs) assert!(worst <= 4 && psnr >= 58.0, "{} (seed {:#x}): max diff {worst}, PSNR {psnr:.2}, first at {first_bad:?}", cfg.name, cfg.seed); } diff --git a/crates/mpeg2v/src/tests.rs b/crates/mpeg2v/src/tests.rs index ca5f2e5e5..eeae62aae 100644 --- a/crates/mpeg2v/src/tests.rs +++ b/crates/mpeg2v/src/tests.rs @@ -1,5 +1,5 @@ //! Unit tests over hand-built streams (headers, robustness). Decoding accuracy against ffmpeg is -//! in `tests/oracle.rs`. +//! in `tests/it/oracle.rs`. use super::*; diff --git a/crates/mpeg2v/tests/fuzz_hostile.rs b/crates/mpeg2v/tests/fuzz_hostile.rs new file mode 100644 index 000000000..5d3aa21a2 --- /dev/null +++ b/crates/mpeg2v/tests/fuzz_hostile.rs @@ -0,0 +1,200 @@ +//! Hostile-input robustness: truncation at every length, byte/bit mutation and header-field +//! sweeps over a small ffmpeg-generated stream must never panic or run away (errors are fine). +//! Skips when ffmpeg is missing. + +#[path = "it/common/mod.rs"] +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use filmcraft_mpeg2v::{Decoder, access_units, probe, scan_access_unit}; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +const SPECS: [common::Spec; 3] = [ + common::Spec { + name: "m2v_fuzz_seed", + lavfi: "testsrc2=size=64x48:rate=25", + frames: 8, + enc: &["-c:v", "mpeg2video", "-bf", "2", "-g", "6", "-b:v", "500k"], + pix_fmt: "yuv420p", + }, + common::Spec { + name: "m2v_fuzz_seed_interlaced", + lavfi: "testsrc2=size=96x64:rate=25,tinterlace=mode=interleave_top,setfield=tff", + frames: 6, + enc: &["-c:v", "mpeg2video", "-flags", "+ilme+ildct", "-bf", "1", "-g", "6", "-b:v", "800k"], + pix_fmt: "yuv420p", + }, + common::Spec { + name: "m2v_fuzz_seed_422", + lavfi: "testsrc2=size=64x48:rate=25", + frames: 4, + enc: &["-c:v", "mpeg2video", "-pix_fmt", "yuv422p", "-profile:v", "0", "-b:v", "1M"], + pix_fmt: "yuv422p", + }, +]; + +static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + let _ = probe(d); + let _ = scan_access_unit(d); + for threads in [false, true] { + let mut dec = Decoder::new(); + dec.set_threads(threads); + for (i, r) in access_units(d).into_iter().enumerate() { + let _ = dec.decode(&d[r], i as i64); + } + // also the whole buffer as one unit + let _ = dec.decode(d, 99); + let _ = dec.flush(); + } + })); + assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=2500` runs all of it (see [`Depth`]). +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpeg2v fuzz") else { return }; + let depth = Depth::from_env(); + for spec in &SPECS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "mpeg2video") { + filmcraft_testkit::skip("mpeg2v fuzz", &format!("this ffmpeg has no mpeg2video encoder (seed {})", spec.name)); + continue; + } + let Some(path) = common::make(&ff, spec) else { + panic!("ffmpeg has mpeg2video but could not encode the {} seed", spec.name); + }; + fuzz(&depth, spec.name, &std::fs::read(path).unwrap()); + } +} + +fn fuzz(depth: &Depth, name: &str, seed: &[u8]) { + // every truncation length (stride keeps it quick on larger seeds) + for n in depth.truncations(seed.len()) { + run_all(&seed[..n], &format!("{name} truncate {n}")); + } + // header region: every byte to 0 / 0xff / 0x80 / each single bit + for p in 0..seed.len().min(96) { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } + let mut d = seed.to_vec(); + d[p] = v; + run_all(&d, &format!("{name} byte {p}={v:#x}")); + } + for b in 0..8 { + if !depth.bit(p, b) { + continue; + } + let mut d = seed.to_vec(); + d[p] ^= 1 << b; + run_all(&d, &format!("{name} flip {p}.{b}")); + } + } + // random multi-mutations + let mut rng = Rng(0x9e3779b97f4a7c15); + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { + let mut d = seed.to_vec(); + for _ in 0..1 + rng.below(6) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + if depth.round(i) { + run_all(&d, &format!("{name} random {i}")); + } + } +} + +const VALUES: [u8; 4] = [0, 0xff, 0x80, 0x01]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 600 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 2500 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 600, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=2500 runs everything)"); + Depth { full: false, truncations: 600, rounds: 2500, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} diff --git a/crates/mpeg2v/tests/common/mod.rs b/crates/mpeg2v/tests/it/common/mod.rs similarity index 100% rename from crates/mpeg2v/tests/common/mod.rs rename to crates/mpeg2v/tests/it/common/mod.rs diff --git a/crates/mpeg2v/tests/it/hostile_dims.rs b/crates/mpeg2v/tests/it/hostile_dims.rs new file mode 100644 index 000000000..01dcf25f8 --- /dev/null +++ b/crates/mpeg2v/tests/it/hostile_dims.rs @@ -0,0 +1,73 @@ +//! A 14-byte sequence header must not make the decoder size its frame buffers at 16383 x 16383 +//! (gigabytes per picture). Hand-built, no ffmpeg. + +use filmcraft_mpeg2v::Decoder; + +#[derive(Default)] +struct Bits { + bytes: Vec, + n: usize, +} + +impl Bits { + fn put(&mut self, value: u32, bits: usize) { + for i in (0..bits).rev() { + if self.n.is_multiple_of(8) { + self.bytes.push(0); + } + if (value >> i) & 1 == 1 + && let Some(b) = self.bytes.last_mut() + { + *b |= 0x80 >> (self.n % 8); + } + self.n += 1; + } + } +} + +/// Sequence header plus sequence extension for a `w` x `h` 4:4:4 stream. +fn sequence(w: u32, h: u32, chroma_format: u32) -> Vec { + let mut s = Bits::default(); + s.put(w & 0xfff, 12); + s.put(h & 0xfff, 12); + s.put(1, 4); // aspect_ratio_information + s.put(4, 4); // frame_rate_code + s.put(1000, 18); // bit_rate_value + s.put(1, 1); // marker + s.put(100, 10); // vbv_buffer_size_value + s.put(0, 3); // constrained_parameters_flag, load_intra, load_non_intra + let mut e = Bits::default(); + e.put(1, 4); // sequence_extension + e.put(0x48, 8); // profile_and_level_indication + e.put(1, 1); // progressive_sequence + e.put(chroma_format, 2); + e.put(w >> 12, 2); + e.put(h >> 12, 2); + e.put(0, 12); // bit_rate_extension + e.put(1, 1); // marker + e.put(0, 8 + 1 + 2 + 5); // vbv_buffer_size_extension, low_delay, frame_rate_extension_n/d + let mut out = vec![0, 0, 1, 0xb3]; + out.extend(s.bytes); + out.extend([0, 0, 1, 0xb5]); + out.extend(e.bytes); + out +} + +fn accepted(w: u32, h: u32, chroma_format: u32) -> bool { + let mut dec = Decoder::new(); + let _ = dec.decode(&sequence(w, h, chroma_format), 0); + dec.sequence().is_some() +} + +#[test] +fn ordinary_sequences_are_accepted() { + assert!(accepted(1920, 1080, 1)); + assert!(accepted(7680, 4320, 3)); +} + +#[test] +fn gigantic_sequences_are_refused() { + assert!(!accepted(16383, 16383, 3)); + assert!(!accepted(16383, 16383, 1)); + assert!(!accepted(8192, 8192, 1)); +} diff --git a/crates/mpeg2v/tests/it/main.rs b/crates/mpeg2v/tests/it/main.rs new file mode 100644 index 000000000..bf473ddbb --- /dev/null +++ b/crates/mpeg2v/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod hostile_dims; +mod oracle; +mod perf; diff --git a/crates/mpeg2v/tests/oracle.rs b/crates/mpeg2v/tests/it/oracle.rs similarity index 99% rename from crates/mpeg2v/tests/oracle.rs rename to crates/mpeg2v/tests/it/oracle.rs index eed298c7d..00473c955 100644 --- a/crates/mpeg2v/tests/oracle.rs +++ b/crates/mpeg2v/tests/it/oracle.rs @@ -3,7 +3,7 @@ //! definition (any IEEE 1180-accurate IDCT conforms), so frames are compared within a tolerance; //! frame count and display order are exact. -mod common; +use crate::common; use common::*; diff --git a/crates/mpeg2v/tests/perf.rs b/crates/mpeg2v/tests/it/perf.rs similarity index 87% rename from crates/mpeg2v/tests/perf.rs rename to crates/mpeg2v/tests/it/perf.rs index 44281499c..10aaa1d6d 100644 --- a/crates/mpeg2v/tests/perf.rs +++ b/crates/mpeg2v/tests/it/perf.rs @@ -1,7 +1,7 @@ -//! Decode speed (ignored; run with `cargo test --release -p filmcraft-mpeg2v --test perf -- -//! --ignored --nocapture`). Uses the 1080i fixture of `tests/oracle.rs` (ffmpeg-encoded, 25 Mb/s). +//! Decode speed (ignored; run with `cargo test --release -p filmcraft-mpeg2v --test it perf:: -- +//! --ignored --nocapture`). Uses the 1080i fixture of `tests/it/oracle.rs` (ffmpeg-encoded, 25 Mb/s). -mod common; +use crate::common; use common::*; diff --git a/crates/mpegts/README.md b/crates/mpegts/README.md index 15bb9101e..a5543992c 100644 --- a/crates/mpegts/README.md +++ b/crates/mpegts/README.md @@ -65,7 +65,7 @@ let au: Vec = file.read_unit(&bytes, v, 0)?; // the first access unit 33-bit unwrap; truncation every 37 bytes and 400 random mutations never panic. - `src/es.rs`, `src/pes.rs`: frame sizes (MPEG audio layers I-III, MPEG-2 LSF, AC-3 at all rates), MPEG-2 and MPEG-1 PES headers. -- `crates/codecs/tests/mpeg_oracle.rs`: FFmpeg-written TS / M2TS / VOB / MPEG-1 system files — +- `crates/codecs/tests/it/mpeg_oracle.rs`: FFmpeg-written TS / M2TS / VOB / MPEG-1 system files — frame counts and every frame's PTS equal ffprobe's, decoded media against FFmpeg (see `filmcraft-codecs`). diff --git a/crates/mxf/Cargo.toml b/crates/mxf/Cargo.toml index 34b62227f..78609cf63 100644 --- a/crates/mxf/Cargo.toml +++ b/crates/mxf/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +chrono = { workspace = true } [dev-dependencies] filmcraft-testkit = { workspace = true } diff --git a/crates/mxf/README.md b/crates/mxf/README.md index 043772477..070045b98 100644 --- a/crates/mxf/README.md +++ b/crates/mxf/README.md @@ -129,7 +129,7 @@ w.finish()?; - `src/tests.rs`: a small OP1a writer builds synthetic files (temporal offsets, key flags, PCM, timecode); open, presentation order, sample bytes, PCM values; truncation at every 37 bytes and 600 random mutations never panic and only list fully-present samples. -- `crates/codecs/tests/mxf_oracle.rs` (FFmpeg-written fixtures, FFmpeg as the decode oracle): +- `crates/codecs/tests/it/mxf_oracle.rs` (FFmpeg-written fixtures, FFmpeg as the decode oracle): H.264 long-GOP with B pictures (bit-exact every frame + 25 random seeks), H.264 29.97 DF timecode, DNxHR LB (±2), ProRes 422 (±1), MPEG-2 long GOP, XDCAM HD422 and D-10 (±4), OP-Atom VC-3 (clip-wrapped) and PCM, D-10 AES3 audio; PCM sample-exact in every file; diff --git a/crates/mxf/src/file.rs b/crates/mxf/src/file.rs index 75dd27991..3394b657c 100644 --- a/crates/mxf/src/file.rs +++ b/crates/mxf/src/file.rs @@ -1,6 +1,7 @@ //! Opening an MXF file: KLV walk, partitions, metadata resolution, sample tables. use std::collections::HashMap; +use std::sync::Arc; use crate::essence::{Codec, PictureInfo, SoundFormat, SoundInfo, decode_pcm, identify_picture, sniff_picture}; use crate::index::{IndexEntry, IndexSegment}; @@ -544,8 +545,8 @@ struct TrackRef { origin: i64, start_position: i64, duration: Option, - descriptor: Option, - sub_descriptors: Vec, + descriptor: Option>, + sub_descriptors: Vec>, body_sid: u32, index_sid: u32, } @@ -567,9 +568,13 @@ fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, } let material = md.of_kind(st::MATERIAL_PACKAGE).next(); let mut out = Vec::new(); + let mut descriptor_cache: HashMap<[u8; 16], Arc> = HashMap::new(); + let intern_descriptor = |set: &Set, cache: &mut HashMap<[u8; 16], Arc>| { + if set.instance_uid == [0; 16] { Arc::new(set.clone()) } else { cache.entry(set.instance_uid).or_insert_with(|| Arc::new(set.clone())).clone() } + }; let mut timecode = material.and_then(|m| package_timecode(md, m)); let name = material.and_then(|m| m.string(tag::PACKAGE_NAME)); - let file_track = |pkg: &Set, kind: Option, id: Option, start: i64, duration: Option, name: Option| -> Option { + let mut file_track = |pkg: &Set, kind: Option, id: Option, start: i64, duration: Option, name: Option| -> Option { let umid = umid_of(pkg, tag::PACKAGE_UID).unwrap_or([0; 32]); for r in pkg.references(tag::PACKAGE_TRACKS) { let Some(t) = md.get(&r) else { continue }; @@ -588,10 +593,16 @@ fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, continue; } let desc = descriptor_for(md, pkg, tid, tk); - let subs: Vec = desc + let descriptor = desc.map(|d| intern_descriptor(d, &mut descriptor_cache)); + let subs: Vec> = desc .and_then(|d| d.dynamic_item(&SUB_DESCRIPTORS_ITEM)) .map(|v| { - crate::klv::batch_of(v).into_iter().filter_map(|b| b.get(..16)).filter_map(|b| md.get(&b.try_into().unwrap_or([0; 16]))).cloned().collect() + crate::klv::batch_of(v) + .into_iter() + .filter_map(|b| b.get(..16)) + .filter_map(|b| md.get(&b.try_into().unwrap_or([0; 16]))) + .map(|s| intern_descriptor(s, &mut descriptor_cache)) + .collect() }) .unwrap_or_default(); let (body_sid, index_sid) = sids.get(&umid).copied().unwrap_or((0, 0)); @@ -604,7 +615,7 @@ fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, origin: t.i(tag::ORIGIN).unwrap_or(0), start_position: start, duration, - descriptor: desc.cloned(), + descriptor, sub_descriptors: subs, body_sid, index_sid, @@ -807,7 +818,7 @@ fn build_track( }; match r.kind { TrackKind::Picture => { - let pi = desc.map(picture_info).unwrap_or_default(); + let pi = desc.map(|d| picture_info(d)).unwrap_or_default(); let mpeg_desc = desc.is_some_and(|d| d.kind() == Some(st::MPEG2_VIDEO_DESCRIPTOR)); let mut codec = identify_picture(pi.picture_coding, container, mpeg_desc); if r.sub_descriptors.iter().any(|s| s.kind() == Some(st::AVC_SUBDESCRIPTOR)) && !matches!(codec, Codec::Avc { .. }) { @@ -828,7 +839,7 @@ fn build_track( build_pictures(src, &mut t, elements, index, warnings)?; } TrackKind::Sound => { - let si = desc.map(sound_info).unwrap_or(SoundInfo { channels: 1, bits: 16, ..Default::default() }); + let si = desc.map(|d| sound_info(d)).unwrap_or(SoundInfo { channels: 1, bits: 16, ..Default::default() }); let d10 = container.is_some_and(|c| c.item_starts_with(&[0x0D, 0x01, 0x03, 0x01, 0x02, 0x01])); let aes_element = d10 || key4[0] == 0x06 && key4[2] == 0x10; let pcm_desc = desc.is_some_and(|d| matches!(d.kind(), Some(st::AES3_DESCRIPTOR) | Some(st::WAVE_DESCRIPTOR))); diff --git a/crates/mxf/src/write/mod.rs b/crates/mxf/src/write/mod.rs index d18fdfb27..6852e1cd1 100644 --- a/crates/mxf/src/write/mod.rs +++ b/crates/mxf/src/write/mod.rs @@ -243,6 +243,26 @@ impl StartTimecode { } } +/// Unix seconds → (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 +/// `i64` exact instead of collapsing out-of-range years to the epoch. +fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + 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, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + /// An MXF timestamp (UTC). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Timestamp { @@ -256,27 +276,19 @@ pub struct Timestamp { } impl Timestamp { - /// From seconds since the Unix epoch (proleptic Gregorian, UTC). + /// From seconds since the Unix epoch (proleptic Gregorian, UTC). Years far outside chrono's own + /// range are preserved (the old hand-rolled conversion kept them) with month, day and time + /// intact; only the `year` is clamped to the MXF `u16` field, exactly as the old code did. pub fn from_unix(secs: i64) -> Timestamp { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); - // civil-from-days (H. Hinnant) - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 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 = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; + use chrono::{Datelike, Timelike}; + let (y, t) = utc_parts(secs); Timestamp { year: y.clamp(0, 65535) as u16, - month: m as u8, - day: d as u8, - hour: (rem / 3600) as u8, - minute: (rem / 60 % 60) as u8, - second: (rem % 60) as u8, + month: t.month() as u8, + day: t.day() as u8, + hour: t.hour() as u8, + minute: t.minute() as u8, + second: t.second() as u8, millisecond: 0, } } diff --git a/crates/mxf/src/write/tests.rs b/crates/mxf/src/write/tests.rs index ddf935211..8728e86c8 100644 --- a/crates/mxf/src/write/tests.rs +++ b/crates/mxf/src/write/tests.rs @@ -267,6 +267,22 @@ fn timestamps() { assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (2023, 11, 14, 22, 13, 20)); let t = Timestamp::from_unix(951_782_400); // 2000-02-29 assert_eq!((t.year, t.month, t.day), (2000, 2, 29)); + // Years far outside chrono's own range are preserved with month/day/time intact and only the + // year clamped to the MXF `u16`, exactly as the old hand-rolled conversion did (not collapsed + // to the epoch — reviewer FredApps). `10_000_000_000_000` s → 318857-05-20 17:46:40. + let t = Timestamp::from_unix(10_000_000_000_000); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 5, 20, 17, 46, 40)); + // Year 10000 (just past chrono's range) still fits the u16 field. + let t = Timestamp::from_unix(253_402_300_800); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (10000, 1, 1, 0, 0, 0)); + // A negative year clamps to 0 but keeps its month/day/time. + let t = Timestamp::from_unix(-10_000_000_000_000); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 8, 13, 6, 13, 20)); + // The `i64` extremes the old code accepted. + let t = Timestamp::from_unix(i64::MAX); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 12, 4, 15, 30, 7)); + let t = Timestamp::from_unix(i64::MIN); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 1, 27, 8, 29, 52)); } #[test] diff --git a/crates/ogg/README.md b/crates/ogg/README.md index a3d7f9196..851b9863e 100644 --- a/crates/ogg/README.md +++ b/crates/ogg/README.md @@ -37,6 +37,6 @@ let packet = ogg.read_packet(&bytes, s, k)?; ## Tests `src/tests.rs` writes synthetic pages: timing with pre-skip and end trimming, packets across pages, -a corrupted page (CRC), every truncation and 300 mutations. `crates/codecs/tests/ogg_oracle.rs` +a corrupted page (CRC), every truncation and 300 mutations. `crates/codecs/tests/it/ogg_oracle.rs` compares with FFmpeg's libopus / Vorbis decodes (exact length; SNR ≥ 50 dB CELT, ≥ 10 dB SILK, Vorbis 139 dB) and checks random seeks against the continuous decode. diff --git a/crates/opus/src/header.rs b/crates/opus/src/header.rs index 2fe0c752a..2e51ce270 100644 --- a/crates/opus/src/header.rs +++ b/crates/opus/src/header.rs @@ -90,6 +90,38 @@ impl OpusHead { v } + /// Validate a header independently of its wire parser. This is required for callers of + /// [`crate::Decoder::from_head`], which may construct an `OpusHead` directly with `usize` + /// values that cannot occur in the byte format. + pub fn validate(&self) -> Result<()> { + if !(1..=255).contains(&self.channels) || self.mapping.len() != self.channels { + return Err(Error::InvalidHeader("bad channel count or mapping length")); + } + if self.mapping_family == 0 { + if self.channels > 2 || self.streams != 1 || self.coupled_streams != self.channels - 1 || self.mapping.iter().copied().ne(0..self.channels as u8) { + return Err(Error::InvalidHeader("invalid family 0 mapping")); + } + return Ok(()); + } + if self.mapping_family == 3 { + return Err(Error::Unsupported("channel mapping family 3 (projection)")); + } + if self.streams == 0 + || self.streams > 255 + || self.coupled_streams > self.streams + || self.streams.checked_add(self.coupled_streams).is_none_or(|n| n > 255) + { + return Err(Error::InvalidHeader("bad stream counts")); + } + if self.mapping_family == 1 && !(1..=8).contains(&self.channels) { + return Err(Error::InvalidHeader("family 1 allows 1 to 8 channels")); + } + if self.mapping.iter().any(|&m| m != 255 && m as usize >= self.streams + self.coupled_streams) { + return Err(Error::InvalidHeader("channel mapping out of range")); + } + Ok(()) + } + fn build(channels: usize, pre_skip: u16, input_sample_rate: u32, output_gain: i16, family: u8, rest: &[u8]) -> Result { if channels == 0 { return Err(Error::InvalidHeader("zero channels")); @@ -120,7 +152,9 @@ impl OpusHead { } (streams, coupled, mapping) }; - Ok(OpusHead { channels, pre_skip, input_sample_rate, output_gain, mapping_family: family, streams, coupled_streams: coupled, mapping }) + let head = OpusHead { channels, pre_skip, input_sample_rate, output_gain, mapping_family: family, streams, coupled_streams: coupled, mapping }; + head.validate()?; + Ok(head) } /// Linear output gain factor. @@ -176,4 +210,19 @@ mod tests { assert_eq!(head.pre_skip, 312); assert_eq!(head.channels, 2); } + + #[test] + fn directly_constructed_headers_are_checked_before_decoder_allocation() { + let head = OpusHead { + channels: 1, + pre_skip: 0, + input_sample_rate: 48_000, + output_gain: 0, + mapping_family: 255, + streams: usize::MAX, + coupled_streams: 0, + mapping: vec![0], + }; + assert!(crate::Decoder::from_head(head, 48_000).is_err()); + } } diff --git a/crates/opus/src/multistream.rs b/crates/opus/src/multistream.rs index a41ba10c3..f47c38179 100644 --- a/crates/opus/src/multistream.rs +++ b/crates/opus/src/multistream.rs @@ -32,6 +32,7 @@ impl Decoder { /// Creates a decoder for a parsed header at `sample_rate` (8/12/16/24/48 kHz). pub fn from_head(head: OpusHead, sample_rate: u32) -> Result { + head.validate()?; let mut streams = Vec::with_capacity(head.streams); for s in 0..head.streams { let ch = if s < head.coupled_streams { 2 } else { 1 }; diff --git a/crates/opus/tests/common/mod.rs b/crates/opus/tests/it/common/mod.rs similarity index 86% rename from crates/opus/tests/common/mod.rs rename to crates/opus/tests/it/common/mod.rs index 6c6ec2754..01cc76605 100644 --- a/crates/opus/tests/common/mod.rs +++ b/crates/opus/tests/it/common/mod.rs @@ -32,12 +32,15 @@ fn spectra(x: &[f32], channels: usize, ch: usize, rate: u32) -> Vec<[f64; NFREQ] let w: Vec = (0..win).map(|i| 0.5 - 0.5 * (2.0 * std::f64::consts::PI * (i as f64 + 0.5) / win as f64).cos()).collect(); let nb = NFREQ / scale; let mut out = Vec::with_capacity(nframes); + let tw = twiddles(win); let mut buf = vec![(0f64, 0f64); win]; + let mut spec = vec![(0f64, 0f64); win]; + let mut scratch = vec![(0f64, 0f64); win]; for f in 0..nframes { for i in 0..win { buf[i] = (x[(f * step + i) * channels + ch] as f64 * w[i], 0.0); } - let spec = fft(&buf); + fft(&buf, 1, &mut spec, &tw, &mut scratch); let mut ps = [0f64; NFREQ]; for (k, p) in ps.iter_mut().enumerate().take(nb) { *p = spec[k].0 * spec[k].0 + spec[k].1 * spec[k].1; @@ -47,28 +50,49 @@ fn spectra(x: &[f32], channels: usize, ch: usize, rate: u32) -> Vec<[f64; NFREQ] out } -/// Recursive mixed-radix DFT (sizes with factors 2, 3, 5). -fn fft(x: &[(f64, f64)]) -> Vec<(f64, f64)> { - let n = x.len(); +/// `e^(-2πi·j/n)` for `j` in `0..n`: the twiddle factors of every sub-transform of a size-`n` FFT. +fn twiddles(n: usize) -> Vec<(f64, f64)> { + (0..n) + .map(|j| { + let ang = -2.0 * std::f64::consts::PI * j as f64 / n as f64; + (ang.cos(), ang.sin()) + }) + .collect() +} + +/// Recursive mixed-radix DFT (sizes with factors 2, 3, 5) of `x[0], x[xs], x[2·xs], …` into +/// `out`. `tw` is `twiddles` of the top-level size, which `out.len()` divides; `scratch` holds at +/// least `out.len()` values. +fn fft(x: &[(f64, f64)], xs: usize, out: &mut [(f64, f64)], tw: &[(f64, f64)], scratch: &mut [(f64, f64)]) { + let n = out.len(); if n == 1 { - return x.to_vec(); + out[0] = x[0]; + return; } let p = [2usize, 3, 5].into_iter().find(|p| n.is_multiple_of(*p)).unwrap_or(n); let m = n / p; - let subs: Vec> = (0..p).map(|r| fft(&x.iter().skip(r).step_by(p).copied().collect::>())).collect(); - let mut out = vec![(0f64, 0f64); n]; - for (k, o) in out.iter_mut().enumerate() { + let stride = tw.len() / n; + for r in 0..p { + fft(&x[r * xs..], xs * p, &mut out[r * m..(r + 1) * m], tw, scratch); + } + for (k, o) in scratch[..n].iter_mut().enumerate() { + let j = k % m; let mut acc = (0f64, 0f64); - for (r, sub) in subs.iter().enumerate() { - let v = sub[k % m]; - let ang = -2.0 * std::f64::consts::PI * (r * k) as f64 / n as f64; - let (c, s) = (ang.cos(), ang.sin()); + // e = r·k mod n, kept below n without a division per term. + let mut e = 0; + for r in 0..p { + let v = out[r * m + j]; + let (c, s) = tw[e * stride]; acc.0 += v.0 * c - v.1 * s; acc.1 += v.0 * s + v.1 * c; + e += k; + if e >= n { + e -= n; + } } *o = acc; } - out + out.copy_from_slice(&scratch[..n]); } /// Opus quality metric in the spirit of `opus_compare` (RFC 6716 §6.1): a per-band, diff --git a/crates/opus/tests/it/main.rs b/crates/opus/tests/it/main.rs new file mode 100644 index 000000000..48dc2080a --- /dev/null +++ b/crates/opus/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod oracle; +mod robustness; +mod vectors; diff --git a/crates/opus/tests/oracle.rs b/crates/opus/tests/it/oracle.rs similarity index 99% rename from crates/opus/tests/oracle.rs rename to crates/opus/tests/it/oracle.rs index 0917479ed..f80236737 100644 --- a/crates/opus/tests/oracle.rs +++ b/crates/opus/tests/it/oracle.rs @@ -7,7 +7,7 @@ //! streams containing SILK differ by the (non-normative) SILK resampler, so they are held to a //! lower SNR bound and the spectral quality metric. -mod common; +use crate::common; use std::process::Command; diff --git a/crates/opus/tests/robustness.rs b/crates/opus/tests/it/robustness.rs similarity index 100% rename from crates/opus/tests/robustness.rs rename to crates/opus/tests/it/robustness.rs diff --git a/crates/opus/tests/vectors.rs b/crates/opus/tests/it/vectors.rs similarity index 99% rename from crates/opus/tests/vectors.rs rename to crates/opus/tests/it/vectors.rs index e36529b7c..7b36fbe7a 100644 --- a/crates/opus/tests/vectors.rs +++ b/crates/opus/tests/it/vectors.rs @@ -7,7 +7,7 @@ //! `.dec` output using our implementation of the opus_compare-style quality metric //! (see `common::opus_quality`), for stereo (`.dec`) and mono (`m.dec`) output at 48 kHz. -mod common; +use crate::common; use std::path::PathBuf; diff --git a/crates/platform/README.md b/crates/platform/README.md index b78c57735..00371ba97 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -289,18 +289,18 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | test | what | |---|---| -| `tests/videotoolbox.rs` (macOS) | H.264 High, HEVC Main (open GOP: CRA + RASL) and HEVC Main 10, 640×360 (coded 368: cropping) with B-frames: every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample, mid-stream `flush`, and a full pass after resets; forced mid-stream failures (`VtDecoder::fail_after`) at five points continue with the software decoder's exact output; seeded mutation of samples and parameter sets (bit flips, truncation, corrupt length prefixes) never panics or hangs; HEVC 4:2:2 10-bit is bit-exact with ffmpeg's decode | -| `tests/fallback.rs` (every OS) | `HybridDecoder` with a stand-in hardware decoder failing after N samples (every sync sample ± a few, first / last sample, after a seek): output identical to the software decoder; in-band parameter sets identical to the sample entry's stay in hardware, different ones switch to software | +| `tests/it/videotoolbox.rs` (macOS) | H.264 High, HEVC Main (open GOP: CRA + RASL) and HEVC Main 10, 640×360 (coded 368: cropping) with B-frames: every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample, mid-stream `flush`, and a full pass after resets; forced mid-stream failures (`VtDecoder::fail_after`) at five points continue with the software decoder's exact output; seeded mutation of samples and parameter sets (bit flips, truncation, corrupt length prefixes) never panics or hangs; HEVC 4:2:2 10-bit is bit-exact with ffmpeg's decode | +| `tests/it/fallback.rs` (every OS) | `HybridDecoder` with a stand-in hardware decoder failing after N samples (every sync sample ± a few, first / last sample, after a seek): output identical to the software decoder; in-band parameter sets identical to the sample entry's stay in hardware, different ones switch to software | | `tests/setting.rs` | Hardware decoding Off gives the software decoder through `make_video_decoder` and the media stack (no hardware frames); Auto gives VideoToolbox where available | -| `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | -| `tests/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | -| `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | -| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | -| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | -| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | -| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | +| `tests/it/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | +| `tests/it/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | +| `tests/it/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | +| `tests/it/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | +| `tests/it/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | +| `tests/it/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | +| `tests/it/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | | `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | -| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | +| `tests/it/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | | `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity | | `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | | `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages | diff --git a/crates/platform/src/hybrid.rs b/crates/platform/src/hybrid.rs index c010e79e0..8323938b8 100644 --- a/crates/platform/src/hybrid.rs +++ b/crates/platform/src/hybrid.rs @@ -21,6 +21,9 @@ const LOG_MAX_SAMPLES: usize = 600; const LOG_MAX_BYTES: usize = 256 << 20; /// Presentation times remembered as returned (older ones are forgotten first). const EMITTED_MAX: usize = 4096; +/// Pictures held while software replays a hardware run. The replay log is bounded by bytes and +/// samples, but an invalid stream can still make one sample produce many decoded pictures. +const REPLAY_OUTPUT_MAX: usize = 4096; /// A hardware decoder with a transparent software fallback. pub struct HybridDecoder { @@ -99,6 +102,16 @@ impl HybridDecoder { } } + /// Retain software replay output without letting a corrupt access unit turn the bounded + /// sample replay into an unbounded frame queue. + fn retain_replay(frames: &mut Vec, mut out: Vec) -> Result<()> { + if frames.len().saturating_add(out.len()) > REPLAY_OUTPUT_MAX { + return Err(CodecError::Decode("software fallback replay produced too many frames".into())); + } + frames.append(&mut out); + Ok(()) + } + /// In-band parameter sets (sequence header, key frame format) that differ from the sample /// entry's (the hardware session was set up from those). fn parameter_sets_changed(&self, sample: &[u8]) -> bool { @@ -157,7 +170,7 @@ impl HybridDecoder { for (i, (s, p)) in log.iter().enumerate() { let Some(sw) = self.sw.as_mut() else { break }; match sw.decode(s, *p) { - Ok(out) => frames.extend(out), + Ok(out) => Self::retain_replay(&mut frames, out)?, Err(e) if i == last => rejected = Some(e), Err(e) => log::warn!("software replay after the hardware decoder failed: {e}"), } @@ -250,3 +263,24 @@ impl VideoDecoder for HybridDecoder { } } } + +#[cfg(test)] +mod tests { + use filmcraft_frame::VideoFrame; + + use super::*; + + fn frame(pts: i64) -> DecodedFrame { + DecodedFrame { pts, frame: VideoFrame::rgba8(1, 1, vec![0, 0, 0, 255]), draft: false } + } + + #[test] + fn replay_output_has_a_hard_limit() { + let mut retained = Vec::new(); + HybridDecoder::retain_replay(&mut retained, (0..REPLAY_OUTPUT_MAX as i64).map(frame).collect()).unwrap(); + + let err = HybridDecoder::retain_replay(&mut retained, vec![frame(REPLAY_OUTPUT_MAX as i64)]).unwrap_err(); + assert!(err.to_string().contains("too many frames")); + assert_eq!(retained.len(), REPLAY_OUTPUT_MAX); + } +} diff --git a/crates/platform/src/videotoolbox.rs b/crates/platform/src/videotoolbox.rs index 2ac6723ca..b1c04740b 100644 --- a/crates/platform/src/videotoolbox.rs +++ b/crates/platform/src/videotoolbox.rs @@ -48,6 +48,16 @@ const NV16_VIDEO: u32 = u32::from_be_bytes(*b"422v"); const NV16_FULL: u32 = u32::from_be_bytes(*b"422f"); const P210_VIDEO: u32 = u32::from_be_bytes(*b"x422"); const P210_FULL: u32 = u32::from_be_bytes(*b"xf22"); +/// H.264 and HEVC parameter parsing both enforce a maximum decoded-picture +/// buffer and reorder depth of 16 pictures. +const MAX_REORDER_DEPTH: usize = 16; + +fn validate_reorder_depth(reorder: usize) -> std::result::Result<(), String> { + if reorder > MAX_REORDER_DEPTH { + return Err("VideoToolbox reorder depth is too large".into()); + } + Ok(()) +} /// How decoded buffers are laid out and what each output picture carries. #[derive(Clone, Debug)] @@ -437,6 +447,7 @@ impl VtDecoder { /// A decoder for the stream, or why VideoToolbox does not take it (unsupported format, no /// hardware decoder, session creation failed). pub fn new(info: NalStreamInfo) -> std::result::Result { + validate_reorder_depth(info.reorder)?; let layout = layout(&info)?; if !hardware_available(info.codec) { return Err("no hardware decoder".into()); @@ -569,3 +580,14 @@ impl VideoDecoder for VtDecoder { self.info.is_disposable(sample) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reorder_depth_matches_supported_stream_limits() { + assert!(validate_reorder_depth(MAX_REORDER_DEPTH).is_ok()); + assert!(validate_reorder_depth(MAX_REORDER_DEPTH + 1).is_err()); + } +} diff --git a/crates/platform/tests/common/mod.rs b/crates/platform/tests/it/common/mod.rs similarity index 96% rename from crates/platform/tests/common/mod.rs rename to crates/platform/tests/it/common/mod.rs index e4f39c59e..74f11bdae 100644 --- a/crates/platform/tests/common/mod.rs +++ b/crates/platform/tests/it/common/mod.rs @@ -9,6 +9,10 @@ use filmcraft_codecs::{DecodedFrame, VideoDecoder}; use filmcraft_frame::PixelData; use filmcraft_isobmff::SampleEntry; +/// NVENC sessions are a limited resource on consumer GPUs, and the encoder counters are +/// process-wide: one NVENC test at a time across every module of the binary. +pub static NVENC_SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// The parity fixtures: (file, ffmpeg arguments). B-frames everywhere (reordering), a 360-line /// picture (coded 368: cropping), two GOPs or more (seeks), and an open-GOP HEVC stream (CRA with /// RASL pictures). diff --git a/crates/platform/tests/fallback.rs b/crates/platform/tests/it/fallback.rs similarity index 84% rename from crates/platform/tests/fallback.rs rename to crates/platform/tests/it/fallback.rs index 8c00e52f5..3b7da8d5c 100644 --- a/crates/platform/tests/fallback.rs +++ b/crates/platform/tests/it/fallback.rs @@ -5,7 +5,7 @@ //! damaged sample that the hardware decoded but the software decoder rejects, inside the replayed //! run, loses no picture. -mod common; +use crate::common; use common::*; use filmcraft_codecs::hw::NalStreamInfo; @@ -51,30 +51,31 @@ fn failures_anywhere_continue_with_the_software_output() { for (name, _) in FIXTURES { let Some(path) = named(&ff, name) else { continue }; let s = read_stream(&path); - let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples); let syncs: Vec = (0..s.samples.len()).filter(|&i| s.sync[i]).collect(); - let mut points: Vec = vec![0, 1, 3, 7, s.samples.len() - 1, s.samples.len()]; - for &k in &syncs { - points.extend([k, k + 1, k + 2, k + 5]); - } + let [_, k, .., last] = syncs[..] else { panic!("{name}: three GOPs or more") }; + // The stream up to a few samples into its last GOP: past the last failure point the + // hybrid only passes the software decoder's output on. + let samples = &s.samples[..(last + 6).min(s.samples.len())]; + let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), samples); + // The hybrid's paths differ by what the replay log holds and what the hardware had + // pending: in the first GOP (nothing out yet, then pictures held for reordering), at and + // just after the second restart point (the log starts over, or an HEVC CRA keeps the GOP + // before it), at the last one (a CRA drops the GOP before the previous one), at the last + // sample and never. Failing anywhere else takes one of those paths. + let points = [0, 1, 3, 7, k, k + 1, k + 2, k + 5, last, last + 2, samples.len() - 1, samples.len()]; for fail_after in points { let mut d = hybrid(&s, fail_after); - let out = decode_all(&mut d, &s.samples); - assert_eq!(d.is_hardware(), fail_after >= s.samples.len(), "{name}: fell back at {fail_after}"); + let out = decode_all(&mut d, samples); + assert_eq!(d.is_hardware(), fail_after >= samples.len(), "{name}: fell back at {fail_after}"); assert_same(&format!("{name} failing after {fail_after} samples"), &out, &reference); } // a failure after a seek (reset) into the second GOP - if let Some(&k) = syncs.get(1) { - let tail = &s.samples[k..]; - let expect = { - let mut sw = filmcraft_codecs::software_video_decoder(&s.entry).unwrap(); - decode_all(sw.as_mut(), tail) - }; - let mut d = hybrid(&s, k + 4); - decode_all(&mut d, &s.samples[..k]); - d.reset(); - assert_same(&format!("{name} failing after a seek"), &decode_all(&mut d, tail), &expect); - } + let tail = &samples[k..]; + let expect = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), tail); + let mut d = hybrid(&s, k + 4); + decode_all(&mut d, &samples[..k]); + d.reset(); + assert_same(&format!("{name} failing after a seek"), &decode_all(&mut d, tail), &expect); } } @@ -179,7 +180,9 @@ fn a_sample_software_rejects_during_the_replay_loses_no_pictures() { // the second restart point, and a disposable picture before it that is not the first sample let Some(next) = (1..s.samples.len()).find(|&i| s.sync[i]) else { continue }; let Some(d) = (2..next.saturating_sub(2)).find(|&i| info.is_disposable(&s.samples[i].0)) else { continue }; - let mut stream = s.samples.clone(); + // the first two GOPs: the replay and the restart after it + let end = (next + 1..s.samples.len()).find(|&i| s.sync[i]).unwrap_or(s.samples.len()); + let mut stream = s.samples[..end].to_vec(); stream[d].0 = damaged(&s.samples[d].0, header); let (reference, rejected) = decode_lenient(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &stream); assert_eq!(rejected, [d], "{name}: the software decoder rejects the damaged sample (and only it)"); diff --git a/crates/platform/tests/hardware_encode.rs b/crates/platform/tests/it/hardware_encode.rs similarity index 100% rename from crates/platform/tests/hardware_encode.rs rename to crates/platform/tests/it/hardware_encode.rs diff --git a/crates/platform/tests/hevc_probe.rs b/crates/platform/tests/it/hevc_probe.rs similarity index 100% rename from crates/platform/tests/hevc_probe.rs rename to crates/platform/tests/it/hevc_probe.rs diff --git a/crates/platform/tests/it/main.rs b/crates/platform/tests/it/main.rs new file mode 100644 index 000000000..b863c7d04 --- /dev/null +++ b/crates/platform/tests/it/main.rs @@ -0,0 +1,13 @@ +mod common; +mod fallback; +mod hardware_encode; +mod hevc_probe; +mod media_foundation; +mod nvenc; +mod nvenc_export; +mod nvenc_hevc; +mod nvenc_hevc_export; +mod nvenc_hevc_main10; +mod nvenc_hevc_probe; +mod nvenc_rgba_input; +mod videotoolbox; diff --git a/crates/platform/tests/media_foundation.rs b/crates/platform/tests/it/media_foundation.rs similarity index 99% rename from crates/platform/tests/media_foundation.rs rename to crates/platform/tests/it/media_foundation.rs index 31c53a7b7..5b7a09d7f 100644 --- a/crates/platform/tests/media_foundation.rs +++ b/crates/platform/tests/it/media_foundation.rs @@ -6,7 +6,7 @@ //! hang. Skips without ffmpeg (fixture generator) or without a hardware decoder. #![cfg(target_os = "windows")] -mod common; +use crate::common; use std::time::Duration; diff --git a/crates/platform/tests/nvenc.rs b/crates/platform/tests/it/nvenc.rs similarity index 100% rename from crates/platform/tests/nvenc.rs rename to crates/platform/tests/it/nvenc.rs diff --git a/crates/platform/tests/nvenc_export.rs b/crates/platform/tests/it/nvenc_export.rs similarity index 95% rename from crates/platform/tests/nvenc_export.rs rename to crates/platform/tests/it/nvenc_export.rs index e0d32a20c..d02bcca35 100644 --- a/crates/platform/tests/nvenc_export.rs +++ b/crates/platform/tests/it/nvenc_export.rs @@ -13,9 +13,6 @@ use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceS use filmcraft_render::SourceMap; use filmcraft_time::{FrameRate, Tick, TimeRange}; -/// The encoder counters are process-wide: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn project(w: u32, h: u32, frames: i64) -> (Arc, filmcraft_project::ItemId, SourceMap) { let rate = FrameRate::FPS_24; let mut p = Project::new("x"); @@ -79,7 +76,7 @@ fn settings(path: String, hw: HardwareEncoding) -> ExportSettings { #[test] fn hardware_export_matches_the_software_export() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); let (p, seq, m) = project(1280, 720, 72); let soft_path = tmp("soft.mp4"); @@ -117,7 +114,7 @@ fn hardware_export_matches_the_software_export() { #[test] fn what_nvenc_does_not_take_goes_to_the_software_encoder() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); let (p, seq, m) = project(640, 360, 24); let run = |name: &str, f: &dyn Fn(&mut ExportSettings)| { diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/it/nvenc_hevc.rs similarity index 95% rename from crates/platform/tests/nvenc_hevc.rs rename to crates/platform/tests/it/nvenc_hevc.rs index acd2bee9b..f0aebcc45 100644 --- a/crates/platform/tests/nvenc_hevc.rs +++ b/crates/platform/tests/it/nvenc_hevc.rs @@ -43,9 +43,6 @@ fn config(w: u32, h: u32) -> Config { } } -/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - type Sources = Vec<(Vec, Vec, Vec)>; /// `None` (the test skips) only on a machine without an HEVC encoder; on one that has it, a @@ -94,7 +91,7 @@ fn hex(b: &[u8]) -> String { #[test] fn the_stream_decodes_to_the_source() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let (w, h) = (1280u32, 720u32); let Some((enc, packets, sources)) = encode_all(&config(w, h), 72) else { return }; assert_eq!(enc.codec(), Codec::Hevc); @@ -179,7 +176,7 @@ fn the_stream_decodes_to_the_source() { #[test] fn nothing_to_encode_with_is_an_error_not_a_crash() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); return; @@ -222,7 +219,7 @@ fn calm(what: &str, f: impl FnOnce() -> T) -> T { #[test] fn hostile_configurations_give_errors_not_crashes() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); return; @@ -266,7 +263,7 @@ fn hostile_configurations_give_errors_not_crashes() { #[test] fn an_encoder_dropped_mid_stream_releases_everything() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); return; @@ -285,7 +282,7 @@ fn an_encoder_dropped_mid_stream_releases_everything() { #[test] fn high_bitrates_move_up_a_level_and_stay_in_the_main_tier() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // 1080p30 at a 12 / 18 Mbit/s peak: above level 4's Main tier (12), where NVENC's own choice was // level 4 High tier, which many hardware decoders refuse for (w, h, fps, kbps, max_kbps, level_idc) in @@ -302,7 +299,7 @@ fn high_bitrates_move_up_a_level_and_stay_in_the_main_tier() { #[test] fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // NVENC wants a GOP longer than the B-frame pattern: short GOPs drop the B-frames, they do not fail for keyint in [1u32, 2] { let cfg = Config { keyint, ..config(640, 360) }; @@ -324,7 +321,7 @@ fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() { #[test] fn vui_timing_matches_integer_and_fractional_frame_rates() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); for fps in [(24, 1), (25, 1), (30_000, 1_001)] { let mut cfg = config(640, 360); cfg.fps = fps; diff --git a/crates/platform/tests/nvenc_hevc_export.rs b/crates/platform/tests/it/nvenc_hevc_export.rs similarity index 97% rename from crates/platform/tests/nvenc_hevc_export.rs rename to crates/platform/tests/it/nvenc_hevc_export.rs index 95f074bb8..5fb79a9f7 100644 --- a/crates/platform/tests/nvenc_hevc_export.rs +++ b/crates/platform/tests/it/nvenc_hevc_export.rs @@ -21,9 +21,6 @@ use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceS use filmcraft_render::SourceMap; use filmcraft_time::{FrameRate, Tick, TimeRange}; -/// The encoder counters are process-wide: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn project(w: u32, h: u32, frames: i64) -> (Arc, filmcraft_project::ItemId, SourceMap) { let rate = FrameRate::FPS_24; let mut p = Project::new("x"); @@ -115,7 +112,7 @@ fn delta(before: filmcraft_export::HwEncodeStats) -> (u64, u64, u64) { #[test] fn hevc_exports_play_everywhere() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); assert_eq!(filmcraft_export::available(Format::Hevc), hevc_available(), "the format is available iff NVENC can encode HEVC"); if !hevc_available() { @@ -173,7 +170,7 @@ fn hevc_exports_play_everywhere() { #[test] fn hevc_export_with_audio() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); @@ -202,7 +199,7 @@ fn hevc_export_with_audio() { #[test] fn sizes_that_are_not_multiples_of_the_coding_block_are_cropped_back() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); @@ -230,7 +227,7 @@ fn sizes_that_are_not_multiples_of_the_coding_block_are_cropped_back() { #[test] fn what_nvenc_cannot_do_is_an_error_that_says_why() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); let rate = FrameRate::FPS_24; let base = hevc(tmp("declined.mp4")); diff --git a/crates/platform/tests/nvenc_hevc_main10.rs b/crates/platform/tests/it/nvenc_hevc_main10.rs similarity index 96% rename from crates/platform/tests/nvenc_hevc_main10.rs rename to crates/platform/tests/it/nvenc_hevc_main10.rs index 76d0c9ccc..1b49795dd 100644 --- a/crates/platform/tests/nvenc_hevc_main10.rs +++ b/crates/platform/tests/it/nvenc_hevc_main10.rs @@ -16,9 +16,6 @@ const W: usize = 1280; const H: usize = 720; const FRAMES: usize = 48; -/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn config(w: u32, h: u32) -> Config { Config { width: w, @@ -134,7 +131,7 @@ fn hex(b: &[u8]) -> String { } fn round_trip(name: &str, signal: ColorSignal, want_sei: bool) { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((enc, packets, sources)) = encode_all(&config(W as u32, H as u32), &signal, FRAMES) else { return }; assert!(enc.is_ten_bit()); assert_eq!(packets.len(), FRAMES, "{name}: one packet per picture"); @@ -239,7 +236,7 @@ fn calm(what: &str, f: impl FnOnce() -> T) -> T { #[test] fn main_10_and_main_encoders_refuse_each_others_pictures() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_hdr_available() { eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder"); return; @@ -268,7 +265,7 @@ fn main_10_and_main_encoders_refuse_each_others_pictures() { #[test] fn hostile_main_10_configurations_give_errors_not_crashes() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_hdr_available() { eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder"); return; @@ -315,7 +312,7 @@ fn hostile_main_10_configurations_give_errors_not_crashes() { #[test] fn the_probe_follows_the_encoder() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let hdr = hevc_hdr_available(); assert!(!hdr || hevc_available(), "Main 10 implies HEVC"); assert_eq!(hevc_hdr_available(), hdr, "the answer is kept"); diff --git a/crates/platform/tests/nvenc_hevc_probe.rs b/crates/platform/tests/it/nvenc_hevc_probe.rs similarity index 100% rename from crates/platform/tests/nvenc_hevc_probe.rs rename to crates/platform/tests/it/nvenc_hevc_probe.rs diff --git a/crates/platform/tests/nvenc_rgba_input.rs b/crates/platform/tests/it/nvenc_rgba_input.rs similarity index 94% rename from crates/platform/tests/nvenc_rgba_input.rs rename to crates/platform/tests/it/nvenc_rgba_input.rs index 4009f3e68..275b1e227 100644 --- a/crates/platform/tests/nvenc_rgba_input.rs +++ b/crates/platform/tests/it/nvenc_rgba_input.rs @@ -6,9 +6,6 @@ use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available}; -/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - /// A moving test picture: gradient background, a moving box, a little texture. fn moving(w: usize, h: usize, i: usize) -> Vec { let mut v = vec![0u8; w * h * 4]; @@ -122,7 +119,7 @@ fn check(profile: Profile) { #[test] fn hevc_rgba_input_converts_like_the_export() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC encoder"); return; @@ -132,7 +129,7 @@ fn hevc_rgba_input_converts_like_the_export() { #[test] fn h264_rgba_input_converts_like_the_export() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !available() { eprintln!("SKIPPED: no NVENC"); return; @@ -142,7 +139,7 @@ fn h264_rgba_input_converts_like_the_export() { #[test] fn main10_has_no_rgba_input_and_short_pictures_are_errors() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC encoder"); return; diff --git a/crates/platform/tests/videotoolbox.rs b/crates/platform/tests/it/videotoolbox.rs similarity index 99% rename from crates/platform/tests/videotoolbox.rs rename to crates/platform/tests/it/videotoolbox.rs index c2ce2f6ae..2db339b1b 100644 --- a/crates/platform/tests/videotoolbox.rs +++ b/crates/platform/tests/it/videotoolbox.rs @@ -6,7 +6,7 @@ //! hang. Skips without ffmpeg (fixture generator) or without a hardware decoder. #![cfg(target_os = "macos")] -mod common; +use crate::common; use std::time::Duration; diff --git a/crates/platform/tests/media_stack.rs b/crates/platform/tests/media_stack.rs index a7d244f5d..e8be399d8 100644 --- a/crates/platform/tests/media_stack.rs +++ b/crates/platform/tests/media_stack.rs @@ -2,6 +2,7 @@ //! decoding Auto and Off (Windows). Its own test binary: the switch is process-wide. #![cfg(target_os = "windows")] +#[path = "it/common/mod.rs"] mod common; use common::*; diff --git a/crates/platform/tests/nvdec.rs b/crates/platform/tests/nvdec.rs index d87f15611..ef1a689dc 100644 --- a/crates/platform/tests/nvdec.rs +++ b/crates/platform/tests/nvdec.rs @@ -4,9 +4,11 @@ //! `reset` + reseek; a forced mid-stream failure must continue in software with the software //! decoder's exact output; what the hardware path does not take is declined; damaged samples give //! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without -//! an NVIDIA GPU. +//! an NVIDIA GPU. Its own test binary: one test turns the process-wide Hardware decoding switch +//! off and on. #![cfg(all(target_os = "linux", target_pointer_width = "64"))] +#[path = "it/common/mod.rs"] mod common; use std::time::Duration; diff --git a/crates/platform/tests/setting.rs b/crates/platform/tests/setting.rs index c58f4a953..259a02c31 100644 --- a/crates/platform/tests/setting.rs +++ b/crates/platform/tests/setting.rs @@ -2,6 +2,7 @@ //! Off gives the software decoder (and no hardware frames), Auto the hardware one where the //! system has it. Its own test binary: the switch is process-wide. +#[path = "it/common/mod.rs"] mod common; use common::*; diff --git a/crates/platform/tests/vaapi.rs b/crates/platform/tests/vaapi.rs index 507e7768c..f7c140661 100644 --- a/crates/platform/tests/vaapi.rs +++ b/crates/platform/tests/vaapi.rs @@ -8,9 +8,11 @@ //! continue in software with the software decoder's exact output; what the hardware path does not //! take (interlaced and 10-bit H.264, 4:2:2 HEVC) is declined; damaged samples and parameter sets give //! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without a -//! VA-API driver. +//! VA-API driver. Its own test binary: one test turns the process-wide Hardware decoding switch off +//! and on. #![cfg(target_os = "linux")] +#[path = "it/common/mod.rs"] mod common; use std::time::Duration; diff --git a/crates/platform/tests/vaapi_displays.rs b/crates/platform/tests/vaapi_displays.rs index 8ec45334a..acdb721d8 100644 --- a/crates/platform/tests/vaapi_displays.rs +++ b/crates/platform/tests/vaapi_displays.rs @@ -7,6 +7,7 @@ //! Skips without ffmpeg (fixture generator) or without a VA-API driver. #![cfg(target_os = "linux")] +#[path = "it/common/mod.rs"] mod common; use common::*; diff --git a/crates/project/src/gtemplate.rs b/crates/project/src/gtemplate.rs index be225a8b6..082cf18b6 100644 --- a/crates/project/src/gtemplate.rs +++ b/crates/project/src/gtemplate.rs @@ -35,6 +35,8 @@ //! The built-in templates ([`builtin_templates`]) are original designs made for FilmCraft in code //! (no image, font or template files), under the project licence. +use std::collections::{HashMap, HashSet}; + use filmcraft_geom::Vec2; use filmcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; @@ -278,24 +280,22 @@ impl GraphicsTemplate { if self.canvas[0] == 0 || self.canvas[1] == 0 || self.canvas[0] > 16384 || self.canvas[1] > 16384 { return bad(format!("bad canvas size {:?}", self.canvas)); } - let mut uids = Vec::new(); + let mut layers = HashMap::with_capacity(self.layers.len()); for (i, l) in self.layers.iter().enumerate() { if !graphic::is_layer(l) { return bad(format!("layer {i} is `{}`, not a graphic layer", l.effect)); } let uid = layer_uid(l); - if uid == 0 || uids.contains(&uid) { + if uid == 0 || layers.insert(uid, l).is_some() { return bad(format!("layer {i} has no unique uid")); } - uids.push(uid); } - let mut ids = Vec::new(); + let mut ids = HashSet::with_capacity(self.controls.len()); for c in &self.controls { - if c.id.is_empty() || ids.contains(&c.id) { + if c.id.is_empty() || !ids.insert(c.id.as_str()) { return bad(format!("control `{}` has no unique id", c.name)); } - ids.push(c.id.clone()); - let Some(l) = self.layers.iter().find(|l| layer_uid(l) == c.layer) else { + let Some(l) = layers.get(&c.layer) else { return bad(format!("control `{}` points at a missing layer", c.name)); }; if c.param != "enabled" && !l.params.contains_key(&c.param) { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 5d95c5b80..5b21cdaec 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1344,6 +1344,11 @@ impl Default for Project { } impl Project { + /// The highest `next_id` a loaded project may carry. Ids are handed out one at a time, so no + /// amount of editing gets near it; a file past it is damaged, and refusing it keeps + /// `alloc_id` from overflowing. + pub const MAX_NEXT_ID: u64 = u64::MAX / 2; + pub fn new(name: &str) -> Self { Self { name: name.into(), @@ -1366,6 +1371,72 @@ impl Project { id } + /// The largest id in use, of any kind. They all come from `next_id`, so a project read from a + /// file must have its `next_id` past this. Ids that are only referenced count too (a clip whose + /// item is gone): a new item taking that id would be picked up by the reference. The root bin + /// counts, so there is always one. + pub fn max_id(&self) -> u64 { + let mut max = 0; + let mut see = |id: u64| max = max.max(id); + let mut bins = vec![&self.root]; + while let Some(bin) = bins.pop() { + see(bin.id.0); + for c in &bin.children { + match c { + BinEntry::Item(i) => see(i.0), + BinEntry::Bin(sub) => bins.push(sub), + } + } + } + self.search_bins.iter().for_each(|b| see(b.id.0)); + // deleting an item leaves these entries behind: a new item taking that id would inherit them + self.transcripts.keys().chain(self.source_graphics.keys()).chain(self.narrations.keys()).for_each(|i| see(i.0)); + // `lut.import` names LUTs `lut` + self.luts.iter().filter_map(|l| l.id.strip_prefix("lut")?.parse().ok()).for_each(&mut see); + fn mixer(m: &MixerStrip) -> impl Iterator + '_ { + m.sends.iter().map(|s| s.target.0).chain(m.output.map(|t| t.0)) + } + for (id, item) in &self.items { + see(id.0); + see(item.id.0); + match &item.kind { + ItemKind::Media(m) => m.markers.iter().for_each(|mk| see(mk.id.0)), + ItemKind::Subclip { parent, .. } => see(parent.0), + ItemKind::Sequence(s) => { + s.markers.iter().for_each(|mk| see(mk.id.0)); + mixer(&s.master_mixer).for_each(&mut see); + for t in s.all_tracks().chain(&s.submix_tracks) { + see(t.id.0); + mixer(&t.mixer).for_each(&mut see); + for c in &t.items { + see(c.id.0); + see(c.item.0); + c.link.into_iter().chain(c.group).for_each(&mut see); + c.markers.iter().for_each(|mk| see(mk.id.0)); + } + for tr in &t.transitions { + see(tr.id.0); + tr.from.into_iter().chain(tr.to).for_each(|c| see(c.0)); + } + } + for t in &s.caption_tracks { + see(t.id.0); + t.captions.iter().for_each(|c| see(c.id.0)); + } + for cam in s.multicam.iter().flat_map(|m| &m.cameras) { + cam.video_track.iter().chain(&cam.audio_tracks).for_each(|t| see(t.0)); + cam.source.iter().for_each(|i| see(i.0)); + } + if let Some(m) = &s.merged { + m.video.iter().chain(&m.audio).for_each(|i| see(i.0)); + } + } + _ => {} + } + } + max + } + /// Add an item to a bin (root when None / not found). pub fn add_item(&mut self, name: &str, label: Label, kind: ItemKind, bin: Option) -> ItemId { let id = ItemId(self.alloc_id()); diff --git a/crates/prores/README.md b/crates/prores/README.md index 99336e2bc..3659d1a0f 100644 --- a/crates/prores/README.md +++ b/crates/prores/README.md @@ -58,7 +58,7 @@ does), so the decoder doesn't need to know. rayon task directly into the output planes. - Robustness: every size field is validated before allocation, codewords longer than 56 bits are rejected, and arithmetic is overflow-safe. Mutated streams return `Err`, never panic (see - `tests/fuzz.rs`). + `tests/it/fuzz.rs`). ## Encoder @@ -79,7 +79,7 @@ does), so the decoder doesn't need to know. ## Accuracy (vs ffmpeg as an external oracle) -`tests/oracle_decode.rs` decodes 29 fixtures made by ffmpeg's `prores_ks` and `prores_aw` +`tests/it/oracle_decode.rs` decodes 29 fixtures made by ffmpeg's `prores_ks` and `prores_aw` encoders. They cover every profile, testsrc2/mandelbrot/SMPTE bars at 1920×1080, 1918×1080, 1280×720, 640×360, 352×288, 70×38 and 333×201, interlaced 720×486 TFF/BFF and 1080i, 2-MB slices, and 4444 with 8- and 16-bit alpha. The results are compared with ffmpeg's decode at the native @@ -89,7 +89,7 @@ bit-identical (86% for 12-bit XQ mandelbrot). Alpha is always bit-exact. The rem differences are the reference decoder's integer-IDCT rounding. This decoder is closer to the exact IDCT and does not reproduce that approximation. -`tests/oracle_encode.rs` encodes 1920×1080 frames with every profile (testsrc2, mandelbrot and +`tests/it/oracle_encode.rs` encodes 1920×1080 frames with every profile (testsrc2, mandelbrot and noisy content; 4444 with an alpha disc) and writes them into a minimal test-only MOV. ffmpeg then decodes them with `-xerror`, with no errors, and agrees with this decoder within ±1. HQ PSNR is 69.9/69.2 dB (Y/C) on mandelbrot at 100% of the target rate. Busy content lands at 99.9–100.4% @@ -97,7 +97,7 @@ of the target. ## Performance -Measured with `cargo test --release -p filmcraft-prores --test perf -- --ignored --nocapture` on +Measured with `cargo test --release -p filmcraft-prores --test it perf:: -- --ignored --nocapture` on an M4 Pro (14 cores) that was heavily loaded by other jobs, so these are lower bounds. A 1080p HQ frame at the full nominal rate (918 KB) decoded at up to 318 fps threaded and 53 fps on one thread. A prores_ks HQ mandelbrot frame (657 KB) decoded at 390 fps threaded. Encoding a 1080p diff --git a/crates/prores/src/decode.rs b/crates/prores/src/decode.rs index d25c49352..60318d848 100644 --- a/crates/prores/src/decode.rs +++ b/crates/prores/src/decode.rs @@ -6,6 +6,10 @@ use crate::header::{FrameHeader, PictureHeader, row_slice_widths}; use crate::tables::*; use crate::{AlphaType, ChromaFormat, Error, Frame, Interlace, Result}; +/// Maximum aggregate decoded plane storage for one ProRes frame (256 MiB). +// 1 GiB: 8K ProRes 4444 with alpha is 283 MB of 16-bit planes +const MAX_FRAME_BYTES: usize = 1 << 30; + /// Decoder options. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DecodeOptions { @@ -120,6 +124,15 @@ pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> let width = hdr.width as usize; let height = hdr.height as usize; let cwidth = hdr.chroma.chroma_width(hdr.width as u32) as usize; + let luma = width.checked_mul(height).ok_or(Error::Invalid("frame dimensions overflow"))?; + let chroma = cwidth.checked_mul(height).ok_or(Error::Invalid("chroma dimensions overflow"))?; + let samples = luma + .checked_add(chroma.checked_mul(2).ok_or(Error::Invalid("chroma plane count overflows"))?) + .and_then(|n| if hdr.alpha == AlphaType::None { Some(n) } else { n.checked_add(luma) }) + .ok_or(Error::Invalid("frame plane count overflows"))?; + if !samples.checked_mul(std::mem::size_of::()).is_some_and(|n| n <= MAX_FRAME_BYTES) { + return Err(Error::Invalid("frame dimensions exceed the decoder memory limit")); + } let interlaced = hdr.interlace != Interlace::Progressive; let mb_width = width.div_ceil(16); let mb_height = if interlaced { height.div_ceil(32) } else { height.div_ceil(16) }; diff --git a/crates/prores/src/tables.rs b/crates/prores/src/tables.rs index a395e906f..975e9d3f5 100644 --- a/crates/prores/src/tables.rs +++ b/crates/prores/src/tables.rs @@ -88,13 +88,13 @@ pub(crate) fn qscale(qidx: u8) -> u32 { /// Orthonormal 1-D DCT-II basis: `BASIS[u][x] = C(u)/2 · cos((2x+1)uπ/16)`, `C(0) = 1/√2`. pub(crate) const BASIS: [[f32; 8]; 8] = [ [0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39], - [0.490_392_64, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.490_392_64], + [0.49039264, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.49039264], [0.461_939_77, 0.191_341_72, -0.191_341_72, -0.461_939_77, -0.461_939_77, -0.191_341_72, 0.191_341_72, 0.461_939_77], - [0.415_734_8, -0.097_545_16, -0.490_392_64, -0.277_785_12, 0.277_785_12, 0.490_392_64, 0.097_545_16, -0.415_734_8], + [0.415_734_8, -0.097_545_16, -0.49039264, -0.277_785_12, 0.277_785_12, 0.49039264, 0.097_545_16, -0.415_734_8], [0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39, 0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39], - [0.277_785_12, -0.490_392_64, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.490_392_64, -0.277_785_12], + [0.277_785_12, -0.49039264, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.49039264, -0.277_785_12], [0.191_341_72, -0.461_939_77, 0.461_939_77, -0.191_341_72, -0.191_341_72, 0.461_939_77, -0.461_939_77, 0.191_341_72], - [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.490_392_64, 0.490_392_64, -0.415_734_8, 0.277_785_12, -0.097_545_16], + [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.49039264, 0.49039264, -0.415_734_8, 0.277_785_12, -0.097_545_16], ]; #[cfg(test)] diff --git a/crates/prores/tests/common/mod.rs b/crates/prores/tests/it/common/mod.rs similarity index 100% rename from crates/prores/tests/common/mod.rs rename to crates/prores/tests/it/common/mod.rs diff --git a/crates/prores/tests/fuzz.rs b/crates/prores/tests/it/fuzz.rs similarity index 99% rename from crates/prores/tests/fuzz.rs rename to crates/prores/tests/it/fuzz.rs index cd012a04c..4b4292f8a 100644 --- a/crates/prores/tests/fuzz.rs +++ b/crates/prores/tests/it/fuzz.rs @@ -1,6 +1,6 @@ //! Robustness: mutated and random inputs must never panic (errors are fine). -mod common; +use crate::common; use filmcraft_prores::*; struct Rng(u64); diff --git a/crates/prores/tests/it/fuzz_hostile.rs b/crates/prores/tests/it/fuzz_hostile.rs new file mode 100644 index 000000000..04a9cb35a --- /dev/null +++ b/crates/prores/tests/it/fuzz_hostile.rs @@ -0,0 +1,107 @@ +//! Hostile-input robustness: every header byte set to extreme values, every bit flipped, +//! truncation at (nearly) every length must never panic or run away (errors are fine). + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use filmcraft_prores::*; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +fn seeds() -> Vec> { + let mut v = Vec::new(); + for (p, w, h) in [(Profile::Proxy, 96u32, 48u32), (Profile::Hq, 70, 38), (Profile::P4444, 64, 32), (Profile::P4444Xq, 48, 20)] { + let chroma = p.chroma(); + let mut f = Frame::new(w, h, chroma, 10, chroma == ChromaFormat::Yuv444); + for (i, s) in f.y.iter_mut().enumerate() { + *s = (64 + (i * 37) % 800) as u16; + } + if let Some(a) = f.alpha.as_mut() { + for (i, s) in a.iter_mut().enumerate() { + *s = if i % 5 == 0 { 1023 } else { (i % 1024) as u16 }; + } + } + let mut cfg = EncoderConfig::new(p, w, h); + cfg.log2_slice_mbs = (w % 4) as u8; + if let Ok(d) = Encoder::with_config(cfg).encode(&f) { + v.push(d); + } + } + v +} + +fn run(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + let res = catch_unwind(AssertUnwindSafe(|| { + for depth in [None, Some(8), Some(16)] { + let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() }); + } + let _ = probe(d); + })); + assert!(res.is_ok(), "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +#[test] +fn hostile_frames_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let seeds = seeds(); + assert!(!seeds.is_empty()); + for (si, seed) in seeds.iter().enumerate() { + let stride = (seed.len() / 400).max(1); + for n in (0..seed.len()).step_by(stride) { + run(&seed[..n], &format!("seed {si} truncate {n}")); + } + for p in 0..seed.len().min(256) { + for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + let mut d = seed.clone(); + d[p] = v; + run(&d, &format!("seed {si} byte {p}={v:#x}")); + } + for b in 0..8 { + let mut d = seed.clone(); + d[p] ^= 1 << b; + run(&d, &format!("seed {si} flip {p}.{b}")); + } + } + // Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions). + for p in 0..seed.len().min(128) { + for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] { + let mut d = seed.clone(); + if let Some(s) = d.get_mut(p..p + 4) { + s.copy_from_slice(&v.to_be_bytes()); + } + run(&d, &format!("seed {si} u32 {p}={v:#x}")); + } + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64); + let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000); + for i in 0..iters { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(8) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + run(&d, &format!("seed {si} random {i}")); + } + } +} diff --git a/crates/prores/tests/it/main.rs b/crates/prores/tests/it/main.rs new file mode 100644 index 000000000..f6423aad1 --- /dev/null +++ b/crates/prores/tests/it/main.rs @@ -0,0 +1,7 @@ +mod common; +mod fuzz; +mod fuzz_hostile; +mod oracle_decode; +mod oracle_encode; +mod perf; +mod roundtrip; diff --git a/crates/prores/tests/oracle_decode.rs b/crates/prores/tests/it/oracle_decode.rs similarity index 99% rename from crates/prores/tests/oracle_decode.rs rename to crates/prores/tests/it/oracle_decode.rs index 1b93eda97..660aceb52 100644 --- a/crates/prores/tests/oracle_decode.rs +++ b/crates/prores/tests/it/oracle_decode.rs @@ -1,6 +1,6 @@ //! Decoder accuracy against ffmpeg's ProRes decoder (oracle only; skipped without ffmpeg). -mod common; +use crate::common; use common::*; use filmcraft_prores::{AlphaType, ChromaFormat, DecodeOptions, Interlace, decode_frame, decode_frame_with, probe}; diff --git a/crates/prores/tests/oracle_encode.rs b/crates/prores/tests/it/oracle_encode.rs similarity index 99% rename from crates/prores/tests/oracle_encode.rs rename to crates/prores/tests/it/oracle_encode.rs index ed269a6d3..5fd9adc9f 100644 --- a/crates/prores/tests/oracle_encode.rs +++ b/crates/prores/tests/it/oracle_encode.rs @@ -1,6 +1,6 @@ //! Encoder conformance and quality against ffmpeg's ProRes decoder (oracle only). -mod common; +use crate::common; use common::*; use filmcraft_prores::{ChromaFormat, DecodeOptions, Encoder, Frame, Profile, decode_frame, decode_frame_with}; diff --git a/crates/prores/tests/perf.rs b/crates/prores/tests/it/perf.rs similarity index 95% rename from crates/prores/tests/perf.rs rename to crates/prores/tests/it/perf.rs index a35e12828..1c9902b93 100644 --- a/crates/prores/tests/perf.rs +++ b/crates/prores/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode/encode throughput. Run with -//! `cargo test --release -p filmcraft-prores --test perf -- --ignored --nocapture`. +//! `cargo test --release -p filmcraft-prores --test it perf:: -- --ignored --nocapture`. -mod common; +use crate::common; use filmcraft_prores::*; use std::time::Instant; diff --git a/crates/prores/tests/roundtrip.rs b/crates/prores/tests/it/roundtrip.rs similarity index 99% rename from crates/prores/tests/roundtrip.rs rename to crates/prores/tests/it/roundtrip.rs index e3b058e0b..b6ced3ba5 100644 --- a/crates/prores/tests/roundtrip.rs +++ b/crates/prores/tests/it/roundtrip.rs @@ -1,6 +1,6 @@ //! Encoder → decoder round trips on synthetic content (no external tools needed). -mod common; +use crate::common; use common::psnr; use filmcraft_prores::*; diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 120785ded..c40b534d9 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -27,6 +27,10 @@ use filmcraft_time::Tick; use crate::gpufx::FxOp; use crate::{Blend, RenderOptions, SourceProvider, motion_matrix, output_size}; +/// A GPU frame plan retains every resolved source frame. Nested sequences may share the same +/// child sequence many times, so recursion depth alone does not bound this expansion. +const MAX_PLAN_LAYERS: usize = 4096; + /// One layer for the GPU, bottom to top. #[derive(Clone)] pub struct PlanLayer { @@ -93,33 +97,63 @@ pub enum PlanStep { } #[derive(Default)] -struct PlanStack(Vec); +struct PlanStack { + steps: Vec, + exhausted: bool, + /// Layers held in `steps`, kept as they are pushed so the cap check stays O(1). + layers: usize, +} impl PlanStack { - fn push(&mut self, layer: PlanLayer) { - if let Some(PlanStep::Layers(layers)) = self.0.last_mut() { - layers.push(layer); - } else { - self.0.push(PlanStep::Layers(vec![layer])); - } + fn layer_count(&self) -> usize { + self.layers } - fn into_layers(self, width: usize, height: usize) -> Vec { + fn push(&mut self, layer: PlanLayer) { + if self.exhausted { + return; + } + if self.layer_count() >= MAX_PLAN_LAYERS { + self.exhausted = true; + return; + } + if let Some(PlanStep::Layers(layers)) = self.steps.last_mut() { + layers.push(layer); + } else { + self.steps.push(PlanStep::Layers(vec![layer])); + } + self.layers = self.layers.saturating_add(1); + } + + fn push_transition(&mut self, inputs: [Vec; 2], effect: filmcraft_project::EffectInstance, progress: f32, scale: f32) { + let added: usize = inputs.iter().map(Vec::len).sum(); + if self.exhausted || self.layer_count().saturating_add(added) > MAX_PLAN_LAYERS { + self.exhausted = true; + return; + } + self.layers = self.layers.saturating_add(added); + self.steps.push(PlanStep::Transition { inputs, effect, progress, scale }); + } + + fn into_layers(self, width: usize, height: usize) -> Option> { + if self.exhausted { + return None; + } match self.finish(width, height) { - FramePlan::Layers { layers, .. } => layers, - plan => vec![PlanLayer::new(cpu_frame(execute_cpu(&plan)), Affine::IDENTITY, 1.0, Blend::Normal)], + FramePlan::Layers { layers, .. } => Some(layers), + plan => Some(vec![PlanLayer::new(cpu_frame(execute_cpu(&plan)), Affine::IDENTITY, 1.0, Blend::Normal)]), } } fn finish(mut self, width: usize, height: usize) -> FramePlan { - if self.0.len() <= 1 && self.0.first().is_none_or(|s| matches!(s, PlanStep::Layers(_))) { - let layers = match self.0.pop() { + if self.steps.len() <= 1 && self.steps.first().is_none_or(|s| matches!(s, PlanStep::Layers(_))) { + let layers = match self.steps.pop() { Some(PlanStep::Layers(layers)) => layers, _ => Vec::new(), }; FramePlan::Layers { width, height, layers } } else { - FramePlan::Composite { width, height, steps: self.0 } + FramePlan::Composite { width, height, steps: self.steps } } } } @@ -200,6 +234,9 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption } let mut layers = PlanStack::default(); push_tracks(project, seq, t, opts, sources, 0, &mut layers); + if layers.exhausted { + return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources)); + } if opts.captions { for o in crate::caption_overlays(seq, t, w, h) { layers.push(PlanLayer::new( @@ -210,6 +247,9 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption )); } } + if layers.exhausted { + return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources)); + } layers.finish(w, h) } @@ -258,6 +298,9 @@ pub(crate) fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool { fn push_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider, nest: u32, layers: &mut PlanStack) { let (w, h) = output_size(seq, opts.scale); for tr in &seq.video_tracks { + if layers.exhausted { + return; + } if !tr.enabled { continue; } @@ -273,7 +316,11 @@ fn push_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, if let Some(item) = item { let mut input = PlanStack::default(); push_item(project, seq, item, t, opts, sources, 1.0, Some(Blend::Normal), nest, &mut input); - *slot = input.into_layers(w, h); + let Some(input_layers) = input.into_layers(w, h) else { + layers.exhausted = true; + return; + }; + *slot = input_layers; } } let progress = if trn.reverse { @@ -282,7 +329,7 @@ fn push_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, } else { p }; - layers.0.push(PlanStep::Transition { inputs, effect: trn.effect.clone(), progress, scale: opts.scale }); + layers.push_transition(inputs, trn.effect.clone(), progress, opts.scale); } "dip_to_black" | "dip_to_white" => { let col = if trn.effect.effect == "dip_to_black" { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }; @@ -345,6 +392,9 @@ fn push_item( nest: u32, out: &mut PlanStack, ) { + if out.exhausted { + return; + } // frame time (`ft`) vs. effect time (`mt`): they differ inside a frame hold without Hold Filters let ft = item.source_time_at(t); let mt = item.effect_time_at(t); @@ -383,6 +433,9 @@ fn push_item( && nest_is_plain(project, seq, nested, ft) { push_tracks(project, nested, ft, opts, sources, nest + 1, out); + if out.exhausted { + return; + } let (w, h) = output_size(nested, opts.scale); for o in crate::caption_overlays(nested, ft, w, h) { out.push(PlanLayer::new( @@ -563,9 +616,20 @@ mod tests { scale: 1.0, }]; let expected = execute_cpu(&FramePlan::Composite { width: 1, height: 1, steps: steps.clone() }); - let layers = PlanStack(steps).into_layers(1, 1); + let layers = PlanStack { steps, exhausted: false, layers: 1 }.into_layers(1, 1).unwrap(); let got = execute_cpu(&FramePlan::Layers { width: 1, height: 1, layers }); assert_eq!(got.px, expected.px); assert!(got.px.iter().any(|v| *v != 0.0)); } + + #[test] + fn layer_plan_has_a_global_budget() { + let frame = Arc::new(VideoFrame::rgba_f32(1, 1, vec![0.0; 4])); + let mut stack = PlanStack::default(); + for _ in 0..=MAX_PLAN_LAYERS { + stack.push(PlanLayer::new(frame.clone(), Affine::IDENTITY, 1.0, Blend::Normal)); + } + assert!(stack.exhausted); + assert_eq!(stack.layer_count(), MAX_PLAN_LAYERS); + } } diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs new file mode 100644 index 000000000..73e898571 --- /dev/null +++ b/crates/render/tests/it/main.rs @@ -0,0 +1,4 @@ +mod median_radius; +mod noise_frame_cadence; +mod sharpen_preview_res; +mod underline_direction; diff --git a/crates/render/tests/median_radius.rs b/crates/render/tests/it/median_radius.rs similarity index 100% rename from crates/render/tests/median_radius.rs rename to crates/render/tests/it/median_radius.rs diff --git a/crates/render/tests/noise_frame_cadence.rs b/crates/render/tests/it/noise_frame_cadence.rs similarity index 100% rename from crates/render/tests/noise_frame_cadence.rs rename to crates/render/tests/it/noise_frame_cadence.rs diff --git a/crates/render/tests/sharpen_preview_res.rs b/crates/render/tests/it/sharpen_preview_res.rs similarity index 100% rename from crates/render/tests/sharpen_preview_res.rs rename to crates/render/tests/it/sharpen_preview_res.rs diff --git a/crates/render/tests/underline_direction.rs b/crates/render/tests/it/underline_direction.rs similarity index 100% rename from crates/render/tests/underline_direction.rs rename to crates/render/tests/it/underline_direction.rs diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index eca8a6c87..3e3f1f1ff 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -14,3 +14,4 @@ workspace = true # use it without creating dependency cycles. [dependencies] png = "0.18" +chrono = { workspace = true } diff --git a/crates/testkit/src/deadline.rs b/crates/testkit/src/deadline.rs new file mode 100644 index 000000000..befb6508a --- /dev/null +++ b/crates/testkit/src/deadline.rs @@ -0,0 +1,66 @@ +//! Hang guard for hostile-input sweeps: a test body runs on a worker thread, and a body that does not finish +//! within the budget fails the test (naming the input it was last working on) instead of stalling CI forever. +//! A merely slow body is not a failure, so a loaded runner or slow VM cannot fail a correct decoder. + +use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +static PROGRESS: Mutex = Mutex::new(String::new()); + +/// Record the input being worked on, so a hang report can name it. Cheap enough to call per input. +pub fn set_progress(label: &str) { + let mut p = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner); + p.clear(); + p.push_str(label); +} + +/// Run `f` on a worker thread and wait at most `secs` seconds (`FILMCRAFT_HOSTILE_TIMEOUT_SECS` overrides the +/// budget). A panic in `f` is re-raised here; a timeout panics with the last [`set_progress`] label. A hung +/// worker is abandoned (the failing test process exits anyway). +pub fn with_deadline(secs: u64, f: impl FnOnce() + Send + 'static) { + let secs = std::env::var("FILMCRAFT_HOSTILE_TIMEOUT_SECS").ok().and_then(|v| v.parse::().ok()).filter(|&n| n > 0).unwrap_or(secs); + run_with_deadline(secs, f); +} + +fn run_with_deadline(secs: u64, f: impl FnOnce() + Send + 'static) { + let (tx, rx) = mpsc::channel(); + // a failed spawn shows up as "worker gone" below + let _ = std::thread::Builder::new().name("hostile deadline".into()).spawn(move || { + let _ = tx.send(catch_unwind(AssertUnwindSafe(f))); + }); + match rx.recv_timeout(Duration::from_secs(secs)) { + Ok(Ok(())) => {} + Ok(Err(payload)) => resume_unwind(payload), + Err(RecvTimeoutError::Timeout) => { + let last = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner).clone(); + panic!("hostile-input test did not finish in {secs}s (hang); last input: {last}"); + } + Err(RecvTimeoutError::Disconnected) => panic!("hostile-input worker thread could not run"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_slow_body_passes() { + with_deadline(30, || std::thread::sleep(Duration::from_millis(100))); + } + + #[test] + #[should_panic(expected = "boom")] + fn a_panic_in_the_body_is_re_raised() { + with_deadline(30, || panic!("boom")); + } + + #[test] + #[should_panic(expected = "last input: seed 7 byte 3")] + fn a_hang_fails_and_names_the_input() { + // a body that outlives the 1 s budget; the inner fn, so FILMCRAFT_HOSTILE_TIMEOUT_SECS cannot lengthen it + set_progress("seed 7 byte 3"); + run_with_deadline(1, || std::thread::sleep(Duration::from_secs(30))); + } +} diff --git a/crates/testkit/src/golden.rs b/crates/testkit/src/golden.rs index 922d4facb..6d52c14d5 100644 --- a/crates/testkit/src/golden.rs +++ b/crates/testkit/src/golden.rs @@ -195,18 +195,31 @@ pub fn diff_image(a: &Rgba8, b: &Rgba8) -> Rgba8 { /// Today's date (UTC) as YYYY-MM-DD, for attribution sidecars. fn today() -> String { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - // civil-from-days (Howard Hinnant) - let z = (secs / 86_400) as i64 + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 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 = yoe + era * 400 + (m <= 2) as i64; - format!("{y:04}-{m:02}-{d:02}") + let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let (y, t) = utc_parts(since.as_secs()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%d")) +} + +/// Unix seconds → (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 and 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) -> (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, 0)); + 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 + } } /// The attribution sidecar text for a generated golden image. @@ -283,6 +296,23 @@ pub fn assert_golden(golden: &Path, actual: &Rgba8, tol: Tolerance, title: &str, mod tests { use super::*; + fn ymd(secs: u64) -> String { + let (y, t) = utc_parts(secs); + format!("{y:04}-{}", t.format("%m-%d")) + } + + #[test] + fn utc_parts_preserves_out_of_range_years() { + // `today()` uses `utc_parts`. Years far outside chrono's own range are preserved (the old + // hand-rolled civil-from-days kept them) rather than collapsing to the epoch. + assert_eq!(ymd(0), "1970-01-01"); + assert_eq!(ymd(1_790_775_907), "2026-09-30"); + assert_eq!(ymd(951_782_400), "2000-02-29"); // 2000 is a leap year + assert_eq!(ymd(253_402_300_800), "10000-01-01"); // just past chrono's range + assert_eq!(ymd(10_000_000_000_000), "318857-05-20"); + assert_eq!(ymd(u64::MAX), "584554051223-11-09"); + } + fn ramp(w: u32, h: u32, alpha: u8) -> Rgba8 { let px = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8, (i / w * 255 / h) as u8, 77, alpha]).collect(); Rgba8::new(w, h, px) diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 9846d9072..a67fbfaa5 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -10,14 +10,17 @@ //! ffmpeg/ffprobe are external test oracles and fixture generators only; they are never linked //! or shipped (AGENTS.md §2). +pub mod deadline; pub mod fixtures; pub mod golden; pub mod oracle; pub mod wav; +pub use deadline::{set_progress, with_deadline}; pub use fixtures::{fixtures_dir, temp_path, workspace_root}; pub use oracle::{ - ffmpeg, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip, + ffmpeg, ffmpeg_has_encoder, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required, + skip, }; /// `let ff = require_ffmpeg!();` — the ffmpeg path, or return from the test after printing diff --git a/crates/testkit/src/oracle.rs b/crates/testkit/src/oracle.rs index e0b13863c..50a1ce22c 100644 --- a/crates/testkit/src/oracle.rs +++ b/crates/testkit/src/oracle.rs @@ -11,6 +11,7 @@ //! `FILMCRAFT_REQUIRE_ORACLES=1` is set (CI), which turns every skip into a failure. use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::OnceLock; /// Environment variable that makes missing oracles a hard failure. @@ -116,6 +117,19 @@ pub fn ffmpeg_or_skip(context: &str) -> Option { tool_or_skip("ffmpeg", ffmpeg(), context) } +/// Whether `ffmpeg -encoders` lists `encoder` (e.g. `libx265`): lets a test tell "this ffmpeg lacks the encoder" +/// (skip) from "the encoder is there but failed" (a real failure). +pub fn ffmpeg_has_encoder(ffmpeg: &Path, encoder: &str) -> bool { + let Ok(out) = Command::new(ffmpeg).args(["-hide_banner", "-encoders"]).stdin(Stdio::null()).output() else { return false }; + encoders_output_lists(&String::from_utf8_lossy(&out.stdout), encoder) +} + +/// Pure function of the `ffmpeg -encoders` output: whether `encoder` is one of the listed names. +fn encoders_output_lists(output: &str, encoder: &str) -> bool { + // rows look like ` V....D libx264 libx264 H.264 …`; the name is the second whitespace-separated token + output.lines().any(|line| line.split_whitespace().nth(1) == Some(encoder)) +} + /// ffprobe, or `None` after reporting a skip (see [`skip`]). pub fn ffprobe_or_skip(context: &str) -> Option { tool_or_skip("ffprobe", ffprobe(), context) @@ -177,6 +191,15 @@ pub fn ffmpeg_with_format_or_skip(context: &str, format_name: &str, min_version: #[cfg(test)] mod tests { use super::*; + + #[test] + fn encoder_lookup_matches_the_name_column_only() { + let out = " V....D libx264 libx264 H.264 / AVC\n V....D libx265 libx265 H.265 / HEVC (libx265)\n A....D aac AAC (libx264 inside)\n"; + assert!(encoders_output_lists(out, "libx264")); + assert!(encoders_output_lists(out, "libx265")); + assert!(!encoders_output_lists(out, "libsvtav1")); + assert!(!encoders_output_lists(out, "H.264")); + } use std::ffi::OsString; fn tmpdir(tag: &str) -> PathBuf { diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 00989623d..811164495 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -21,3 +21,6 @@ unicode-bidi = "0.3.18" unicode-linebreak = "0.1.5" # UAX #29 extended grapheme clusters (MIT OR Apache-2.0). unicode-segmentation = "1.13" + +[dev-dependencies] +tempfile = "3.27" diff --git a/crates/text/build.rs b/crates/text/build.rs index 715a8b7df..17a5bf843 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -5,7 +5,8 @@ //! `crates/text`) this embeds every font listed in its //! `fonts/manifest.txt` as [`CRAFT_FONTS`](crate::fonts::CRAFT_FONTS); unset, `CRAFT_FONTS` is //! empty and nothing else changes. A `CRAFT_FONTS_DIR` that is not a checkout is a warning, or an -//! error with `CRAFT_FONTS_REQUIRED=1` (release builds). +//! error with `CRAFT_FONTS_REQUIRED=1` (release builds), which also makes an unset or empty +//! `CRAFT_FONTS_DIR` an error. //! //! Native builds embed every font. The web (wasm32) build embeds only the UI font (BIZ UDPGothic //! Regular, ~4.5 MB) to stay under the web size budget (Cloudflare serves files up to 25 MiB). @@ -18,15 +19,18 @@ fn main() { println!("cargo::rerun-if-changed=build.rs"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some_and(|v| v == "1"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); - if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - match craft_fonts(&dir) { + match std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from) { + Some(dir) => match craft_fonts(&dir) { Ok(entries) => src.push_str(&entries), - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } + Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()), Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), + }, + None if required => { + println!("cargo::error=CRAFT_FONTS_REQUIRED=1 but CRAFT_FONTS_DIR is unset or empty: point it at a craft-fonts checkout (absolute path)"); } + None => {} } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a6c3f444f..add11df73 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -114,16 +114,46 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for e in rd.flatten() { let p = e.path(); - if p.is_dir() { + let Ok(file_type) = e.file_type() else { continue }; + // Font discovery is bounded to the configured folders: never follow a symlinked folder + // into a loop or an unrelated tree. Symlinked font files (NixOS, home-manager, Debian + // alternatives) still count. + let is_file = if file_type.is_symlink() { std::fs::metadata(&p).is_ok_and(|m| m.is_file()) } else { file_type.is_file() }; + if file_type.is_dir() { if depth < 4 { walk(&p, depth + 1, out); } - } else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { + } else if is_file && p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { out.push(p); } } } +#[cfg(all(test, unix))] +mod directory_tests { + use super::walk; + + #[test] + fn directory_source_does_not_follow_symlinked_font_trees() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let configured = root.path().join("configured"); + let foreign = root.path().join("foreign"); + std::fs::create_dir_all(&configured).unwrap(); + std::fs::create_dir_all(&foreign).unwrap(); + std::fs::write(configured.join("inside.ttf"), b"").unwrap(); + std::fs::write(foreign.join("outside.ttf"), b"").unwrap(); + symlink(&foreign, configured.join("loop")).unwrap(); + symlink(foreign.join("outside.ttf"), configured.join("linked.ttf")).unwrap(); + + let mut found = Vec::new(); + walk(&configured, 0, &mut found); + found.sort(); + assert_eq!(found, vec![configured.join("inside.ttf"), configured.join("linked.ttf")]); + } +} + impl FontSource for DirectorySource { fn faces(&self) -> Vec { let mut files = Vec::new(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 50fbed58e..68d485e7e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -544,10 +544,18 @@ fn paragraph(text: &str, base: usize, sty: &Styles, para: &ParagraphStyle) -> Ve let mut cluster_starts: Vec = extents.keys().copied().collect(); cluster_starts.sort_unstable(); let char_bytes: Vec = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect(); + let mut first_member = 0usize; for (ci, &cs) in cluster_starts.iter().enumerate() { let (x0, x1, rtl) = extents[&cs]; let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end); - let members: Vec = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect(); + while first_member < char_bytes.len() && char_bytes[first_member] < cs { + first_member += 1; + } + let member_start = first_member; + while first_member < char_bytes.len() && char_bytes[first_member] < next { + first_member += 1; + } + let members = &char_bytes[member_start..first_member]; let n = members.len().max(1) as f32; for (mi, b) in members.iter().enumerate() { let f = mi as f32 / n; diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 940767db1..2d99030bb 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -131,35 +131,62 @@ pub struct GlyphMask { type GKey = (FaceId, u32, u32, u8, bool, bool); -fn glyph_cache() -> &'static Mutex>>> { - static C: OnceLock>>>> = OnceLock::new(); +/// No single glyph mask may consume more than this much memory. Larger glyphs are filled directly +/// into the caller's clipped destination instead of first allocating their complete bounds. +const MAX_GLYPH_MASK_BYTES: usize = 16 << 20; +/// The glyph cache is shared by every project in the process, so constrain its bytes as well as +/// its number of entries. +const MAX_GLYPH_CACHE_BYTES: usize = 64 << 20; + +#[derive(Default)] +struct GlyphCache { + entries: HashMap>>, + bytes: usize, +} + +fn glyph_cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); C.get_or_init(Default::default) } +fn glyph_mask_bounds(path: &Path) -> Option<(i32, i32, usize, usize)> { + let (x0, y0, x1, y1) = path.bounds()?; + let (left, top, right, bottom) = (x0.floor() as i64, y0.floor() as i64, x1.ceil() as i64, y1.ceil() as i64); + let (left, top) = (i32::try_from(left).ok()?, i32::try_from(top).ok()?); + let w = usize::try_from(right.saturating_sub(i64::from(left))).ok()?.max(1); + let h = usize::try_from(bottom.saturating_sub(i64::from(top))).ok()?.max(1); + let bytes = w.checked_mul(h)?.checked_mul(std::mem::size_of::())?; + (bytes <= MAX_GLYPH_MASK_BYTES).then_some((left, top, w, h)) +} + /// Rasterise glyph `g` at pixel size `px` with horizontal phase `frac` (0..1, quantised to ¼). pub fn glyph_mask(g: &Glyph, px: f32, frac: f32) -> Option> { let size_q = (px * 8.0).round().max(1.0) as u32; let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4; let key = (g.face, g.id, size_q, frac_q, g.synth_bold, g.synth_italic); - if let Some(v) = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + if let Some(v) = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()).entries.get(&key) { return v.clone(); } let px = size_q as f32 / 8.0; let fq = frac_q as f32 / 4.0; let gg = Glyph { x: 0.0, y: 0.0, size: px, ..*g }; let path = glyph_path(&gg, &[1.0, 0.0, 0.0, 1.0, fq, 0.0]); - let v = path.bounds().map(|(x0, y0, x1, y1)| { - let left = x0.floor() as i32; - let top = y0.floor() as i32; - let w = (x1.ceil() as i32 - left).max(1) as usize; - let h = (y1.ceil() as i32 - top).max(1) as usize; - Arc::new(GlyphMask { mask: fill(&path, w, h, -left as f32, -top as f32), left, top }) - }); + let Some((left, top, w, h)) = glyph_mask_bounds(&path) else { + // Do not cache an oversized rejection: callers render it directly into their bounded + // output, and retaining attacker-selected keys would itself become a cache DoS. + return None; + }; + let v = Some(Arc::new(GlyphMask { mask: fill(&path, w, h, -left as f32, -top as f32), left, top })); + let bytes = w * h * std::mem::size_of::(); let mut m = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()); - if m.len() > 16_384 { - m.clear(); + if m.entries.len() > 16_384 || m.bytes.checked_add(bytes).is_none_or(|n| n > MAX_GLYPH_CACHE_BYTES) { + m.entries.clear(); + m.bytes = 0; } - m.insert(key, v.clone()); + if let Some(old) = m.entries.insert(key, v.clone()) { + m.bytes = m.bytes.saturating_sub(old.as_ref().map_or(0, |g| g.mask.a.len() * std::mem::size_of::())); + } + m.bytes = m.bytes.saturating_add(bytes); v } @@ -223,6 +250,12 @@ pub fn draw_run(l: &Layout, m: &Xform, out: &mut Mask, origin: (i32, i32), run: let xi = x.floor(); if let Some(gm) = glyph_mask(g, px, x - xi) { out.add(&gm.mask, xi as i32 + gm.left - ox, y.round() as i32 + gm.top - oy); + } else { + // A huge glyph's mask would be mostly clipped away. Rasterise its outline straight + // into `out`, whose allocation is already bounded by the caller's canvas. + let gm = compose(m, &[1.0, 0.0, 0.0, 1.0, g.x, g.y]); + let p = glyph_path(g, &gm); + fill_into(&p, out, -ox as f32, -oy as f32); } } if !l.underlines.is_empty() { @@ -280,6 +313,12 @@ mod tests { assert!(g.top < 0, "rises above the baseline"); } + #[test] + fn oversized_glyph_mask_is_rejected_before_allocation() { + assert!(glyph_mask_bounds(&Path::rect(0.0, 0.0, 100_000.0, 100_000.0)).is_none()); + assert!(glyph_mask_bounds(&Path::rect(0.0, 0.0, 32.0, 32.0)).is_some()); + } + #[test] fn cached_and_outline_paths_agree() { let l = layout("Title 42", &TextStyle { size: 48.0, ..Default::default() }, &ParagraphStyle::default()); diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index c000f4423..fec6ed38d 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -108,22 +108,43 @@ fn name_string(b: &[u8], id: u16) -> Option { if nid != id { continue; } - let raw = b.get(storage + off..storage + off + len)?; - let (rank, s) = match plat { + let rank = match plat { 3 if enc == 1 || enc == 10 => { + if best.as_ref().is_some_and(|(current, _)| *current <= if lang == 0x409 { 0 } else { 1 }) { + continue; + } + if lang == 0x409 { 0 } else { 1 } + } + 0 => { + if best.as_ref().is_some_and(|(current, _)| *current <= 2) { + continue; + } + 2 + } + 1 if enc == 0 => { + if best.as_ref().is_some_and(|(current, _)| *current <= 3) { + continue; + } + 3 + } + _ => continue, + }; + // Only decode a record that can improve the selected name. A hostile name table may + // point thousands of records at the same huge string. + let raw = b.get(storage + off..storage + off + len)?; + let s = match plat { + 3 => { let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u)) + String::from_utf16_lossy(&u) } 0 => { let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (2, String::from_utf16_lossy(&u)) + String::from_utf16_lossy(&u) } - 1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()), - _ => continue, + 1 => raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect(), + _ => return None, }; - if best.as_ref().is_none_or(|(r0, _)| rank < *r0) { - best = Some((rank, s)); - } + best = Some((rank, s)); } best.map(|(_, s)| s).filter(|s| !s.trim().is_empty()) } @@ -185,4 +206,27 @@ mod tests { assert_eq!(faces.len(), 1); assert_eq!((faces[0].index, faces[0].family.as_str(), faces[0].style.as_str()), (1, "Inter", "Regular")); } + + #[test] + fn later_non_improving_name_records_are_not_decoded() { + // The first record is the preferred Windows-English value. The second names an invalid + // range: it must be ignored instead of making the whole name lookup fail. + let mut table = vec![0; 30]; + table[2..4].copy_from_slice(&2u16.to_be_bytes()); + table[4..6].copy_from_slice(&30u16.to_be_bytes()); + let first = 6usize; + table[first..first + 2].copy_from_slice(&3u16.to_be_bytes()); + table[first + 2..first + 4].copy_from_slice(&1u16.to_be_bytes()); + table[first + 4..first + 6].copy_from_slice(&0x409u16.to_be_bytes()); + table[first + 6..first + 8].copy_from_slice(&1u16.to_be_bytes()); + table[first + 8..first + 10].copy_from_slice(&4u16.to_be_bytes()); + let second = first + 12; + table[second..second + 2].copy_from_slice(&3u16.to_be_bytes()); + table[second + 2..second + 4].copy_from_slice(&1u16.to_be_bytes()); + table[second + 4..second + 6].copy_from_slice(&0x409u16.to_be_bytes()); + table[second + 6..second + 8].copy_from_slice(&1u16.to_be_bytes()); + table[second + 8..second + 10].copy_from_slice(&u16::MAX.to_be_bytes()); + table.extend_from_slice(&[0, b'A', 0, b'B']); + assert_eq!(name_string(&table, 1).as_deref(), Some("AB")); + } } diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 2cb8004a8..e9adb40f3 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -121,6 +121,7 @@ impl SubAssign for Tick { } /// A half-open time range `[start, start + duration)`. +/// Zero or negative durations are empty and never overlap another range. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct TimeRange { pub start: Tick, @@ -141,7 +142,7 @@ impl TimeRange { t >= self.start && t < self.end() } pub fn overlaps(&self, o: &TimeRange) -> bool { - self.start < o.end() && o.start < self.end() + !self.is_empty() && !o.is_empty() && self.start < o.end() && o.start < self.end() } pub fn intersect(&self, o: &TimeRange) -> Option { let s = self.start.max(o.start); @@ -480,7 +481,15 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i } else { parts .iter() - .map(|p| if p.is_empty() { Ok(0) } else { p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) }) + .map(|p| { + if p.is_empty() { + Ok(0) + } else if !p.bytes().all(|b| b.is_ascii_digit()) { + Err(ParseError(format!("bad field `{p}`"))) + } else { + p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) + } + }) .collect::>()? }; if nums.len() > 4 { @@ -514,6 +523,28 @@ mod tests { use super::*; use proptest::prelude::*; + #[test] + fn empty_time_ranges_never_overlap() { + let clip = TimeRange::new(Tick(0), Tick(10)); + for duration in [0, -1, -5] { + let empty = TimeRange::new(Tick(5), Tick(duration)); + assert!(empty.is_empty()); + assert_eq!(empty.intersect(&clip), None); + assert!(!empty.overlaps(&clip), "duration {duration}"); + assert!(!clip.overlaps(&empty), "duration {duration}"); + } + } + + #[test] + fn time_range_overlap_respects_half_open_boundaries() { + let clip = TimeRange::new(Tick(0), Tick(10)); + for (start, duration, expected) in [(-5, 5, false), (10, 5, false), (-5, 6, true), (9, 5, true), (2, 3, true)] { + let other = TimeRange::new(Tick(start), Tick(duration)); + assert_eq!(clip.overlaps(&other), expected); + assert_eq!(other.overlaps(&clip), expected); + } + } + #[test] fn seconds_display_has_no_negative_zero() { let f = |t: i64| format_time(Tick(t), FrameRate::default(), false, TimeDisplay::Seconds, 48000); @@ -720,6 +751,14 @@ mod tests { assert!(parse_timecode("abc", r, false, 0).is_err()); } + #[test] + fn signed_field_rejected() { + let r = FrameRate::FPS_25; + assert!(parse_timecode("00:00:-5:00", r, false, 0).is_err()); + assert!(parse_timecode("00:+1:00:00", r, false, 0).is_err()); + assert_eq!(parse_timecode("00:00:10:00", r, false, 0).unwrap(), 250); + } + #[test] fn display_modes() { let r = FrameRate::FPS_24; diff --git a/crates/tts-text/src/normalize.rs b/crates/tts-text/src/normalize.rs index 1378b7269..34a213c32 100644 --- a/crates/tts-text/src/normalize.rs +++ b/crates/tts-text/src/normalize.rs @@ -44,6 +44,8 @@ const MAX_INPUT: usize = 1_048_576; const MAX_PAUSE_MS: u32 = 10_000; /// Numbers with more than this many digits are read digit by digit (never overflow). const MAX_CARDINAL_DIGITS: usize = 12; +/// A malformed pause marker is only inspected within this bounded prefix. +const MAX_PAUSE_MARKER_BYTES: usize = 64; /// Normalize `text` into spoken-word tokens for `lang`. /// @@ -95,6 +97,24 @@ pub fn normalize(text: &str, lang: Lang) -> Result, NormalizeError> { out.push(Token::Punct(c)); } } + '-' if matches!(peek(rest, 1), Some('$' | '€' | '£')) + && !text + .get(..pos) + .and_then(|before| before.chars().next_back()) + .is_some_and(|b| b.is_alphanumeric() || matches!(b, '$' | '€' | '£' | '-')) => + { + // A negative amount ("-$5"): keep the sign so a loss is not read as a gain. + // A hyphen right after a word or amount ("$10-$20") is a range, not a sign. + pos += 1; + match parse_money(rest.get(1..).unwrap_or(""), lang) { + Some((words, used)) => { + pos += used; + out.push(Token::Word("minus".into())); + push_words(&mut out, &words); + } + None => out.push(Token::Punct(c)), + } + } _ if c.is_ascii_digit() => match parse_number_cluster(rest, lang) { Some((tokens, used)) => { pos += used; @@ -139,7 +159,9 @@ pub fn normalize(text: &str, lang: Lang) -> Result, NormalizeError> { out.push(terminator); } } - _ if is_word_char(c) => match parse_wordish(rest, lang) { + // Apostrophes can occur inside a word, but an apostrophe-only run must not repeatedly + // scan the whole remaining script looking for a word. + _ if c.is_alphabetic() => match parse_wordish(rest, lang) { Some((tokens, used)) => { pos += used; out.extend(tokens); @@ -184,7 +206,8 @@ fn peek(s: &str, i: usize) -> Option { /// `[pause 1s]`, `[pause 500ms]`, `[pause 1.5s]` → a capped pause. Malformed markers /// return `None` and fall through to plain text (never an error). fn parse_pause(rest: &str) -> Option<(u32, usize)> { - let close = rest.find(']')?; + // search bytes: the 64-byte cut may fall inside a multi-byte character that follows the marker + let close = rest.as_bytes().iter().take(MAX_PAUSE_MARKER_BYTES).position(|&b| b == b']')?; let inner = rest.get(1..close)?.trim(); let value = inner.strip_prefix("pause")?; let value = value.trim_start(); @@ -413,6 +436,8 @@ fn parse_time(rest: &str) -> Option<(Vec, usize)> { for (lit, letter_pair) in [("am", ['a', 'm']), ("pm", ['p', 'm'])] { if after.get(..2).is_some_and(|s| s.eq_ignore_ascii_case(lit)) { for letter in letter_pair { + // Uppercase "A" so it is read as a letter name, not the article. + let letter = if letter == 'a' { 'A' } else { letter }; tokens.push(Token::Word(letter.into())); } used += 2; @@ -932,19 +957,20 @@ fn year_words(year: u64, _lang: Lang) -> Vec { /// Words, abbreviations, months, acronyms, a.m./p.m. and e.g./i.e. fn parse_wordish(rest: &str, _lang: Lang) -> Option<(Vec, usize)> { - let collected: String = rest.chars().take_while(|c| is_word_char(*c)).collect(); + let end = rest.char_indices().find_map(|(i, c)| (!is_word_char(c)).then_some(i)).unwrap_or(rest.len()); + let collected = rest.get(..end)?; let word = collected.trim_matches(|c| c == '\'' || c == '’'); if word.is_empty() { return None; } - let used = word.len() + leading_apostrophes(rest); + let used = word.len(); let lower = word.to_ascii_lowercase(); // a.m. / p.m. — a single letter, a dot, "m", and a dot or boundary. if word.len() == 1 && matches!(lower.as_str(), "a" | "p") && peek(rest, used) == Some('.') && matches!(peek(rest, used + 1), Some('m') | Some('M')) { let after_m = used + 2; if peek(rest, after_m).is_none_or(|c| !c.is_alphanumeric()) { - let mut tokens = vec![Token::Word(word.to_ascii_lowercase())]; + let mut tokens = vec![Token::Word(if lower == "a" { "A".into() } else { lower.clone() })]; tokens.push(Token::Word("m".into())); let consumed = if peek(rest, after_m) == Some('.') { after_m + 1 } else { after_m }; return Some((tokens, consumed)); @@ -979,10 +1005,6 @@ fn parse_wordish(rest: &str, _lang: Lang) -> Option<(Vec, usize)> { Some((vec![Token::Word(word.into())], used)) } -fn leading_apostrophes(rest: &str) -> usize { - rest.chars().take_while(|c| *c == '\'' || *c == '’').map(char::len_utf8).sum() -} - /// Expansions for `Mr.` etc.; the dot is consumed so it never ends a sentence. /// `St.` picks "saint" before a capitalised name and "street" otherwise. fn dotted_abbreviation(lower: &str, rest: &str, used: usize) -> Option<(Vec, usize)> { @@ -1237,6 +1259,24 @@ mod tests { assert_eq!(words("$0.50", Lang::EnUs), "zero dollars and fifty cents"); } + #[test] + fn negative_money_keeps_its_sign() { + for lang in [Lang::EnUs, Lang::EnGb] { + assert_eq!(words("-$5", lang), "minus five dollars"); + assert_eq!(words("-€3.50", lang), "minus three euros and fifty cents"); + assert_eq!(words("-£2", lang), "minus two pounds"); + assert_eq!(words("a refund of -$1.05", lang), "a refund of minus one dollar and five cents"); + assert_eq!(words("(-$5)", lang), "( minus five dollars )"); + // A hyphen between two amounts is a range, not a sign. + assert_eq!(words("$10-$20", lang), "ten dollars - twenty dollars"); + assert_eq!(words("€5-€8", lang), "five euros - eight euros"); + assert_eq!(words("$10.50-$20", lang), "ten dollars and fifty cents - twenty dollars"); + // A hyphen after a word or before a non-amount stays punctuation. + assert_eq!(words("a-$5", lang), "a - five dollars"); + assert!(!words("-$x", lang).contains("minus")); + } + } + #[test] fn money_fraction_beyond_two_digits_is_not_hundreds_of_cents() { for lang in [Lang::EnUs, Lang::EnGb] { @@ -1327,7 +1367,7 @@ mod tests { assert_eq!(words("23:59", Lang::EnUs), "twenty-three fifty-nine"); assert_eq!(words("3:30pm", Lang::EnUs), "three thirty p m"); assert_eq!(words("3:30 p.m.", Lang::EnUs), "three thirty p m"); - assert_eq!(words("9:00 a.m.", Lang::EnUs), "nine zero a m"); + assert_eq!(words("9:00 a.m.", Lang::EnUs), "nine zero A m"); } #[test] @@ -1455,6 +1495,9 @@ mod tests { assert_eq!(words("[pause 0.25s]", Lang::EnUs), "[pause 250]"); assert_eq!(words("[pause 11s]", Lang::EnUs), "[pause 10000]"); assert_eq!(words("[pause 999999999999s]", Lang::EnUs), "[pause 10000]"); + // byte 64 falls inside an `é` after the marker: still a pause + let tokens = normalize(&format!("[pause 1s]x{}", "é".repeat(40)), Lang::EnUs).unwrap(); + assert_eq!(tokens.first(), Some(&Token::Pause { millis: 1000 })); // Malformed markers stay plain text. assert_eq!(words("[pause", Lang::EnUs), "[ pause"); assert_eq!(words("[pause x]", Lang::EnUs), "[ pause x ]"); @@ -1507,6 +1550,14 @@ mod tests { assert_eq!(words("1,2345", Lang::EnUs), "one , two thousand three hundred forty-five"); } + #[test] + fn malformed_marker_and_apostrophe_runs_make_linear_progress() { + let brackets = "[".repeat(20_000); + let apostrophes = "'".repeat(20_000); + assert_eq!(normalize(&brackets, Lang::EnUs).unwrap().len(), 20_000); + assert_eq!(normalize(&apostrophes, Lang::EnUs).unwrap().len(), 20_000); + } + #[test] fn hostile_inputs_do_not_panic() { let hostile: Vec = vec![ diff --git a/crates/tts-text/src/phonemize.rs b/crates/tts-text/src/phonemize.rs index 6d6c1f33d..d3e0882df 100644 --- a/crates/tts-text/src/phonemize.rs +++ b/crates/tts-text/src/phonemize.rs @@ -27,6 +27,10 @@ pub const MAX_CHUNK_CHARS: usize = 400; const MAX_DICT_BYTES: usize = 16 * 1024 * 1024; /// Maximum number of dictionary entries. const MAX_DICT_ENTRIES: usize = 500_000; +/// Most ARPAbet symbols retained for one dictionary pronunciation. +const MAX_PRONUNCIATION_PHONES: usize = 256; +/// Long unknown words are not useful narration and can make suffix fallback recurse repeatedly. +const MAX_LETTER_TO_SOUND_CHARS: usize = 512; /// Maximum number of overrides honoured (excess ignored, not an error). const MAX_OVERRIDES: usize = 1_000; /// Maximum length of one override value (longer ones ignored). @@ -98,7 +102,7 @@ impl Lexicon { skipped += 1; continue; }; - let phones: Vec<&str> = parts.collect(); + let phones: Vec<&str> = parts.by_ref().take(MAX_PRONUNCIATION_PHONES + 1).collect(); if phones.is_empty() { if had_comment { continue; @@ -106,6 +110,10 @@ impl Lexicon { skipped += 1; continue; } + if phones.len() > MAX_PRONUNCIATION_PHONES { + skipped += 1; + continue; + } // `WORD(2)` alternates fold into the same key. let key = match (head.find('('), head.rfind(')')) { (Some(open), Some(close)) if close > open + 1 && head.get(open + 1..close).is_some_and(|d| d.chars().all(|c| c.is_ascii_digit())) => { @@ -298,19 +306,34 @@ fn flush_sentence(sentence: &mut Vec, chunks: &mut Vec, lex: &Lexi /// Split a phoneme string longer than [`MAX_CHUNK_CHARS`] at the last `,` `;` `:` /// before the limit, else at the last space, else hard-split at the limit. fn split_long_chunk(s: String) -> Vec { - if s.chars().count() <= MAX_CHUNK_CHARS { - return vec![s]; + let mut out = Vec::new(); + let mut start = 0usize; + while start < s.len() { + let rest = &s[start..]; + let Some((limit, _)) = rest.char_indices().nth(MAX_CHUNK_CHARS) else { + let piece = rest.trim().to_string(); + if !piece.is_empty() { + out.push(piece); + } + break; + }; + let head = &rest[..limit]; + let cut = head + .char_indices() + .rev() + .find(|&(_, c)| matches!(c, ',' | ';' | ':')) + .map(|(i, _)| i + 1) + .or_else(|| head.rfind(' ').map(|i| i + 1)) + .unwrap_or(limit); + let piece = head[..cut].trim_end(); + if !piece.is_empty() { + out.push(piece.to_string()); + } + start += cut; + while s.get(start..).and_then(|rest| rest.chars().next()).is_some_and(char::is_whitespace) { + start += s[start..].chars().next().map(char::len_utf8).unwrap_or(0); + } } - let limit = s.char_indices().map(|(i, _)| i).nth(MAX_CHUNK_CHARS).unwrap_or(s.len()); - let head_boundary = s.get(..limit).and_then(|head| { - head.char_indices().rev().find(|&(_, c)| matches!(c, ',' | ';' | ':')).map(|(i, _)| i + 1).or_else(|| head.rfind(' ').map(|i| i + 1)).or(Some(limit)) - }); - let Some(cut) = head_boundary else { return vec![s] }; - let head = s.get(..cut).unwrap_or("").trim_end().to_string(); - let tail = s.get(cut..).unwrap_or("").trim_start().to_string(); - let mut out = vec![head]; - out.extend(split_long_chunk(tail)); - out.retain(|p| !p.is_empty()); out } @@ -698,7 +721,7 @@ fn past_ed(last: &Phone) -> &'static str { /// syllable (on the vowel before -tion/-sion). Always pronounceable. fn letter_to_sound(word: &str) -> Option { let letters: Vec = word.chars().filter(|c| c.is_ascii_alphabetic()).collect(); - if letters.is_empty() { + if letters.is_empty() || letters.len() > MAX_LETTER_TO_SOUND_CHARS { return None; } // -ture / -sure endings (nature, measure): the e belongs to the suffix, so it @@ -741,10 +764,9 @@ fn letter_to_sound(word: &str) -> Option { let mut i = 0usize; while i < n { let Some(rest) = chars.get(i..) else { break }; - let rest_str: String = rest.iter().collect(); let at_start = i == 0; // Multi-letter rules first (longest match wins). - if rest_str.ends_with("tion") && rest_str.len() == 4 { + if rest.len() == 4 && rest.iter().copied().eq("tion".chars()) { // The vowel before the suffix takes the primary stress. if let Some(&last) = vowel_positions.last() && let Some(v) = out.get_mut(last) @@ -756,7 +778,7 @@ fn letter_to_sound(word: &str) -> Option { out.push("n".into()); break; } - if rest_str.ends_with("sion") && rest_str.len() == 4 { + if rest.len() == 4 && rest.iter().copied().eq("sion".chars()) { if let Some(&last) = vowel_positions.last() && let Some(v) = out.get_mut(last) { @@ -767,35 +789,35 @@ fn letter_to_sound(word: &str) -> Option { out.push("n".into()); break; } - if rest_str.starts_with("sch") { + if rest.starts_with(&['s', 'c', 'h']) { out.push("s".into()); out.push("k".into()); i += 3; continue; } - if rest_str == "sten" { + if rest == ['s', 't', 'e', 'n'] { // listen out.push("s".into()); out.push("ə".into()); out.push("n".into()); break; } - if rest_str == "gue" { + if rest == ['g', 'u', 'e'] { out.push("ɡ".into()); break; } - if rest_str == "que" { + if rest == ['q', 'u', 'e'] { out.push("k".into()); break; } - if rest_str.starts_with("ough") { + if rest.starts_with(&['o', 'u', 'g', 'h']) { // A famously irregular group; pick the "thought" vowel, f when final. out.push("ɔ".into()); - if rest_str == "ough" { + if rest == ['o', 'u', 'g', 'h'] { out.push("f".into()); break; } - if rest_str.starts_with("ought") { + if rest.starts_with(&['o', 'u', 'g', 'h', 't']) { out.push("t".into()); i += 5; continue; @@ -803,13 +825,13 @@ fn letter_to_sound(word: &str) -> Option { i += 3; continue; } - if rest_str.starts_with("gu") && rest_str.len() > 2 { + if rest.starts_with(&['g', 'u']) && rest.len() > 2 { // The u is silent before a vowel (guard, guess). out.push("ɡ".into()); i += 2; continue; } - if rest_str.starts_with("ps") && at_start { + if rest.starts_with(&['p', 's']) && at_start { out.push("s".into()); i += 2; continue; @@ -1170,6 +1192,18 @@ PERCENT P ER0 S EH1 N T assert_eq!(lex.pronunciations("TRAIL").map(|v| v.first().map(String::as_str)), Some(Some("AE1 T"))); } + #[test] + fn skips_an_unreasonably_long_dictionary_pronunciation() { + let mut input = String::from("GOOD AE1\nTOO_LONG"); + for _ in 0..=MAX_PRONUNCIATION_PHONES { + input.push_str(" AE1"); + } + input.push('\n'); + let lex = Lexicon::parse_cmudict(input.as_bytes()).unwrap(); + assert_eq!(lex.len(), 1); + assert_eq!(lex.skipped(), 1); + } + #[test] fn refuses_oversized_dictionaries() { let oversized = vec![b'a'; MAX_DICT_BYTES + 1]; @@ -1335,6 +1369,29 @@ PERCENT P ER0 S EH1 N T } } + #[test] + fn chunk_splitting_is_iterative_for_many_chunks() { + let input = "a".repeat(MAX_CHUNK_CHARS * 2_000); + let chunks = split_long_chunk(input); + assert_eq!(chunks.len(), 2_000); + assert!(chunks.iter().all(|chunk| chunk.len() == MAX_CHUNK_CHARS)); + } + + #[test] + fn letter_to_sound_refuses_an_absurdly_long_unknown_word() { + assert!(letter_to_sound(&"ture".repeat(MAX_LETTER_TO_SOUND_CHARS)).is_none()); + } + + #[test] + fn am_reads_the_letter_name_not_the_article() { + for text in ["At 9:00 a.m.", "At 9:00am"] { + let chunks = phonemize(text, Lang::EnUs, &test_lex(), &Overrides::default()).unwrap(); + let joined = format!("{chunks:?}"); + assert!(joined.contains("ˈA"), "{text}: {joined}"); + assert!(!joined.contains('ə'), "{text}: {joined}"); + } + } + #[test] fn every_output_char_is_in_symbols() { let inputs = [ diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b63880d4e..f1d7b8991 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -53,6 +53,7 @@ filmcraft-codecs = { workspace = true } filmcraft-platform = { workspace = true } filmcraft-interchange = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } +tempfile = "3" # build.rs bakes contributors/contributors.json into the binary (About ▸ Contributors). [build-dependencies] diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index faefbf7f7..1d8b10f96 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -38,9 +38,15 @@ translated label and its English source, including Unicode capitals. Project con and preference values retain their original values. Engine errors, CLI and MCP messages remain English. Ukrainian and Russian currently cover the core menus; they fall back to English elsewhere. -Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders, -literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual -checks use the control channel to switch language and capture the resulting panels. +A string a catalog lacks shows in English, so a missing translation never fails a test or a +build. `cargo xtask i18n-report` prints each language's missing strings (menus, `tl!` literals and +registry names) and its entries whose source the interface no longer has; `cargo xtask ci` prints +the same report in its log. + +Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders +and UI behaviour in other languages (switching, search, font coverage); `cargo xtask ci` runs the +workspace gates. Visual checks use the control channel to switch language and capture the resulting +panels. ## Source playback diff --git a/crates/ui-egui/examples/bench/sections.rs b/crates/ui-egui/examples/bench/sections.rs index 27231b732..3f5192bef 100644 --- a/crates/ui-egui/examples/bench/sections.rs +++ b/crates/ui-egui/examples/bench/sections.rs @@ -454,8 +454,7 @@ pub fn export(o: &Opts) -> Vec { return Vec::new(); }; let secs = if o.quick { 3.0 } else { 8.0 }; - let dir = std::env::temp_dir().join(format!("filmcraft-bench-export-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("tmp dir"); + let dir = tempfile::Builder::new().prefix("filmcraft-bench-export-").tempdir().expect("tmp dir"); let mut rows = Vec::new(); // Scenes: (id, format, label, extension, effects-heavy). `--only` matches the id exactly here, // so `--only h264` runs the plain scene and `--only h264_fx` only the layered one. @@ -497,7 +496,7 @@ pub fn export(o: &Opts) -> Vec { }) .expect("audio"); frames = s.project.sequence(seq).map(|q| q.settings.frame_rate.frame_at(q.duration())).unwrap_or(0); - let out = dir.join(format!("out.{ext}")); + let out = dir.path().join(format!("out.{ext}")); filmcraft_export::reset_export_lock_wait(); filmcraft_gpu::export_renderer::timing::reset(); let (t0, c0) = (Instant::now(), cpu_now()); @@ -549,7 +548,6 @@ pub fn export(o: &Opts) -> Vec { "gpu_fallbacks": filmcraft_engine::export::gpu_render_stats().fallbacks.saturating_sub(gpu0.fallbacks), })); } - let _ = std::fs::remove_dir_all(&dir); rows } @@ -567,9 +565,8 @@ pub fn project(o: &Opts) -> Vec { for k in 0..5 { clips += big_sequence(&mut s, item, &format!("Sequence {}", k + 1), 11 + k as u64).1; } - let dir = std::env::temp_dir().join(format!("filmcraft-bench-project-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("tmp dir"); - let file = dir.join("big.fcproj"); + let dir = tempfile::Builder::new().prefix("filmcraft-bench-project-").tempdir().expect("tmp dir"); + let file = dir.path().join("big.fcproj"); let n = o.repeat.max(3); let (mut save, mut open) = (Vec::new(), Vec::new()); for _ in 0..n { @@ -583,7 +580,6 @@ pub fn project(o: &Opts) -> Vec { assert_eq!(s2.project.items.len(), s.project.items.len()); } let mbytes = std::fs::metadata(&file).map(|m| m.len()).unwrap_or(0) as f64 / 1e6; - let _ = std::fs::remove_dir_all(&dir); eprintln!("project: save {:.0} ms, open {:.0} ms ({clips} clips, {mbytes:.1} MB)", median(&save), median(&open)); let row = |what: &str, v: &[f64]| json!({"what": what, "clips": clips, "mbytes": mbytes, "p50_ms": median(v), "min_ms": v.iter().cloned().fold(f64::MAX, f64::min), "runs_ms": v, "load": load_avg()}); vec![row("save (file.saveAs)", &save), row("open (file.open)", &open)] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3b5391125..960fa8915 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -25,6 +25,17 @@ use std::sync::mpsc::Sender; use serde_json::{Value, json}; +const MAX_CLICK_COUNT: u64 = 10; +const MAX_DRAG_STEPS: u64 = 1_024; + +fn bounded_repetitions(params: &Value, key: &str, default: u64, minimum: u64, maximum: u64) -> Result { + let value = params.get(key).map(|value| value.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))).transpose()?.unwrap_or(default); + if value > maximum { + return Err(format!("`{key}` must not exceed {maximum}")); + } + Ok(value.max(minimum)) +} + use crate::FilmcraftApp; use crate::dock::PanelKind; use crate::state::{Mode, PlaybackRes, ThumbnailMode, Tool}; @@ -320,6 +331,15 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()), Err(e) => return err(e), }; + // validate before queuing anything, so a refused count leaves no modifier held + let count = if req.method == "ui.click" { + match bounded_repetitions(p, "count", 1, 0, MAX_CLICK_COUNT) { + Ok(count) => count, + Err(error) => return err(error), + } + } else { + 0 + }; let m = modifiers(p); app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(pos)); @@ -329,7 +349,6 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Some("middle") => egui::PointerButton::Middle, _ => egui::PointerButton::Primary, }; - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); for _ in 0..count { app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); @@ -345,7 +364,10 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) (Err(e), _) | (_, Err(e)) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()), (Err(e), _) | (_, Err(e)) => return err(e), }; - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); + let steps = match bounded_repetitions(p, "steps", 12, 2, MAX_DRAG_STEPS) { + Ok(steps) => steps, + Err(error) => return err(error), + }; let m = modifiers(p); app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); @@ -622,10 +644,20 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result, String> { #[cfg(test)] mod base64_tests { + use serde_json::json; + #[test] fn rfc4648_vectors() { for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] { assert_eq!(super::base64(i.as_bytes()), o); } } + + #[test] + fn synthetic_event_repetition_is_bounded() { + assert_eq!(super::bounded_repetitions(&json!({}), "count", 1, 0, super::MAX_CLICK_COUNT).unwrap(), 1); + assert_eq!(super::bounded_repetitions(&json!({"steps": 1}), "steps", 12, 2, super::MAX_DRAG_STEPS).unwrap(), 2); + assert!(super::bounded_repetitions(&json!({"count": u64::MAX}), "count", 1, 0, super::MAX_CLICK_COUNT).is_err()); + assert!(super::bounded_repetitions(&json!({"steps": "many"}), "steps", 12, 2, super::MAX_DRAG_STEPS).is_err()); + } } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index eef1c3682..0de0dae95 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -6,8 +6,8 @@ //! //! # Looking strings up //! - `tl!` with a string literal: that UI string in the language the UI is drawn in (see -//! [`set_current`]). Use it for every label, button, heading, tooltip and hint; the tests check -//! that each `tl!` literal has a Spanish translation. +//! [`set_current`]). Use it for every label, button, heading, tooltip and hint; `cargo xtask +//! i18n-report` lists the `tl!` literals each catalog lacks. //! - [`t`]: the same for a string that is not a literal (a name from a registry or a list). //! - [`tr_ctx`]: when one English word needs different translations (the Edit menu, the Edit button). //! - [`fmt`]: fill `{name}` placeholders after translating a template; translators may reorder them. @@ -349,22 +349,30 @@ pub fn korean_font_available() -> bool { mod tests { use super::*; + /// The catalog file of a language (none for English, the source language). + fn catalog_text(l: Language) -> Option<&'static str> { + match l { + Language::En => None, + Language::Ja => Some(include_str!("ja.tsv")), + Language::Es => Some(include_str!("es.tsv")), + Language::PtBr => Some(include_str!("pt-br.tsv")), + Language::Uk => Some(include_str!("uk.tsv")), + Language::ZhCn => Some(include_str!("zh-cn.tsv")), + Language::De => Some(include_str!("de.tsv")), + Language::Ru => Some(include_str!("ru.tsv")), + Language::Fr => Some(include_str!("fr.tsv")), + Language::Id => Some(include_str!("id.tsv")), + Language::It => Some(include_str!("it.tsv")), + Language::Ko => Some(include_str!("ko.tsv")), + Language::Cs => Some(include_str!("cs.tsv")), + } + } + #[test] fn catalogs_are_well_formed() { - for (code, text) in [ - ("es", include_str!("es.tsv")), - ("ja", include_str!("ja.tsv")), - ("pt-br", include_str!("pt-br.tsv")), - ("uk", include_str!("uk.tsv")), - ("zh-cn", include_str!("zh-cn.tsv")), - ("de", include_str!("de.tsv")), - ("ru", include_str!("ru.tsv")), - ("fr", include_str!("fr.tsv")), - ("id", include_str!("id.tsv")), - ("it", include_str!("it.tsv")), - ("ko", include_str!("ko.tsv")), - ("cs", include_str!("cs.tsv")), - ] { + for l in Language::ALL { + let Some(text) = catalog_text(l) else { continue }; + let code = l.code(); let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); for (i, (ctx, en, tr)) in entries.iter().enumerate() { @@ -376,105 +384,55 @@ mod tests { assert_eq!(en.ends_with('…'), tr.ends_with('…'), "{code}: ellipsis differs in {en:?} → {tr:?}"); } } - assert_eq!(Language::Es.tr("File"), "Archivo"); - assert_eq!(Language::Ja.tr("File"), "ファイル"); - assert_eq!(Language::En.tr("File"), "File"); - assert_eq!(Language::Es.tr("mi video.mp4"), "mi video.mp4"); - assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); - assert_eq!(Language::parse("es"), Some(Language::Es)); - assert_eq!(Language::parse("xx"), None); - assert_eq!(Language::PtBr.tr("File"), "Arquivo"); - assert_eq!(Language::PtBr.tr("meu video.mp4"), "meu video.mp4"); - assert_eq!(Language::PtBr.name(), "Português (Brasil)"); - assert_eq!(Language::Uk.name(), "Українська"); - assert_eq!(Language::Uk.tr("File"), "Файл"); - assert_eq!(Language::Uk.tr("мій кліп.mp4"), "мій кліп.mp4"); - assert_eq!(Language::Uk.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::ZhCn.name(), "简体中文"); - assert_eq!(Language::ZhCn.tr("File"), "文件"); - assert_eq!(Language::ZhCn.tr("我的视频.mp4"), "我的视频.mp4"); - assert_eq!(Language::De.tr("File"), "Datei"); - assert_eq!(Language::De.tr("Save As…"), "Speichern unter…"); - assert_eq!(Language::De.tr("Größe ändern.mp4"), "Größe ändern.mp4"); - assert_eq!(Language::De.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::De.name(), "Deutsch"); - assert_eq!(Language::parse("de"), Some(Language::De)); - assert_eq!(Language::parse("de-DE"), None, "the preference stores the bare code"); - assert_eq!(Language::Ru.name(), "Русский"); - assert_eq!(Language::Ru.tr("File"), "Файл"); - assert_eq!(Language::Ru.tr("мой клип.mp4"), "мой клип.mp4"); - assert_eq!(Language::Ru.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::Fr.name(), "Français"); - assert_eq!(Language::Fr.tr("File"), "Fichier"); - assert_eq!(Language::Fr.tr("Save As…"), "Enregistrer sous…"); - assert_eq!(Language::Fr.tr("Montage d'été.mp4"), "Montage d'été.mp4"); - assert_eq!(Language::Fr.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::parse("fr"), Some(Language::Fr)); - assert_eq!(Language::parse("fr-FR"), None, "the preference stores the bare code"); - assert_eq!(Language::Id.name(), "Bahasa Indonesia"); - assert_eq!(Language::Id.tr("File"), "Berkas"); - assert_eq!(Language::Id.tr("Save As…"), "Simpan Sebagai…"); - assert_eq!(Language::Id.tr("Liburan keluarga.mp4"), "Liburan keluarga.mp4"); - assert_eq!(Language::Id.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::parse("id"), Some(Language::Id)); - assert_eq!(Language::parse("id-ID"), None, "the preference stores the bare code"); - assert_eq!(Language::It.name(), "Italiano"); - assert_eq!(Language::It.tr("File"), "File"); - assert_eq!(Language::It.tr("Save As…"), "Salva con nome…"); - assert_eq!(Language::It.tr("Vacanze d'estate.mp4"), "Vacanze d'estate.mp4"); - assert_eq!(Language::It.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::parse("it"), Some(Language::It)); - assert_eq!(Language::parse("it-IT"), None, "the preference stores the bare code"); - assert_eq!(Language::Cs.name(), "Čeština"); - assert_eq!(Language::Cs.tr("File"), "Soubor"); - assert_eq!(Language::Cs.tr("Save As…"), "Uložit jako…"); - assert_eq!(Language::parse("cs"), Some(Language::Cs)); - assert_eq!(Language::parse("cs-CZ"), None, "the preference stores the bare code"); + } + + #[test] + fn unknown_strings_pass_through_and_languages_round_trip() { for l in Language::ALL { + for text in ["An untranslated label", "mi video.mp4", "日本語の文書.pdf", "Größe ändern.mp4", "Montage d'été.mp4"] { + assert_eq!(l.tr(text), text, "{}", l.code()); + } assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); assert_eq!(json, format!("\"{}\"", l.code())); assert_eq!(serde_json::from_str::(&json).unwrap(), l); } + assert_eq!(Language::En.tr("File"), "File"); + assert_eq!(Language::parse("xx"), None); + for code in ["de-DE", "fr-FR", "id-ID", "it-IT", "cs-CZ"] { + assert_eq!(Language::parse(code), None, "the preference stores the bare code"); + } + // language names are always shown in their own language + for (l, name) in [ + (Language::PtBr, "Português (Brasil)"), + (Language::Uk, "Українська"), + (Language::ZhCn, "简体中文"), + (Language::De, "Deutsch"), + (Language::Ru, "Русский"), + (Language::Fr, "Français"), + (Language::Id, "Bahasa Indonesia"), + (Language::It, "Italiano"), + (Language::Ko, "한국어"), + (Language::Cs, "Čeština"), + ] { + assert_eq!(l.name(), name); + } assert_eq!(fmt("{b} y {a}", &[("a", "1"), ("b", "2")]), "2 y 1"); assert_eq!(fmt("{a} {zz}", &[("a", "1")]), "1 {zz}"); } - #[test] - fn czech_catalog_covers_the_complete_source_catalog_and_ui_literals() { - let (entries, errors) = catalog::parse_entries(include_str!("cs.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for catalog_text in [include_str!("es.tsv"), include_str!("fr.tsv")] { - let (source_entries, _) = catalog::parse_entries(catalog_text); - for (context, source, _) in source_entries { - assert!(entries.iter().any(|(c, s, _)| c == &context && s == &source), "missing catalog entry: {context:?} {source:?}"); - } - } - for (_, source) in sources() { - for literal in tl_literals(&source) { - assert!(Language::Cs.has(&literal), "missing translated UI literal: {literal:?}"); - } - } - for name in dynamic_names() { - assert!(Language::Cs.has(&name), "missing translated registry name: {name:?}"); - } - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let menu_items = crate::menus::menu_items(&app); - for label in crate::menus::MENUS.into_iter().chain(menu_items.iter().map(|it| it.label.as_str())) { - if translatable(label) && !Language::ALL.iter().any(|language| language.name() == label) { - assert!(Language::Cs.has(label), "missing translated menu label: {label:?}"); - } - } - } - #[test] fn the_current_language_is_per_thread() { + // any entry the Spanish catalog has + let (entries, _) = catalog::parse_entries(include_str!("es.tsv")); + let Some((_, en, es)) = entries.into_iter().find(|(ctx, en, es)| ctx.is_empty() && en != es) else { return }; set_current(Language::Es); - assert_eq!((current(), t("File")), (Language::Es, "Archivo")); + assert_eq!((current(), t(&en)), (Language::Es, es.as_str())); // another thread (another test) still draws in English - assert_eq!(std::thread::spawn(|| t("File").to_string()).join().ok().as_deref(), Some("File")); + let source = en.clone(); + assert_eq!(std::thread::spawn(move || t(&source).to_string()).join().ok(), Some(en.clone())); set_current(Language::En); - assert_eq!(t("File"), "File"); + assert_eq!(t(&en), en); } #[test] @@ -484,170 +442,75 @@ mod tests { assert_eq!(fmt("{missing} y {unfinished", &[]), "{missing} y {unfinished"); } + /// A search for a language's label finds the item, and so does its English name. #[test] - fn searches_match_spanish_labels_and_english_names() { - set_current(Language::Es); - assert!(matches_query("Gaussian Blur", "desenfoque")); - assert!(matches_query("Gaussian Blur", "gaussian")); - assert!(matches_query("Selection Tool", &"SELECCIÓN".to_lowercase())); - assert!(!matches_query("Gaussian Blur", "selección")); + fn searches_match_translated_labels_and_english_names() { + for l in Language::ALL { + set_current(l); + let blur = l.tr("Gaussian Blur").to_lowercase(); + let selection = l.tr("Selection Tool").to_lowercase(); + assert!(matches_query("Gaussian Blur", &blur), "{}: {blur}", l.code()); + assert!(matches_query("Gaussian Blur", "gaussian"), "{}", l.code()); + assert!(matches_query("Selection Tool", &selection), "{}: {selection}", l.code()); + assert!(!matches_query("Gaussian Blur", &selection), "{}: {selection}", l.code()); + } set_current(Language::En); } + /// `tl!` and `tlf!` take plain string literals: the catalog scan (`tl_literals`) reads no other kind. #[test] - fn spanish_translates_every_menu_label() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && !Language::Es.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); - } - }; - for top in crate::menus::MENUS { - need(top); - } - for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); - } - } - missing.sort(); - assert!(missing.is_empty(), "untranslated menu labels: {missing:#?}"); - } - - /// The core menus every other catalog must cover: the entries of the menus-only Ukrainian - /// catalog, which `ukrainian_entries_cover_the_original_menu_catalog` keeps to menu labels (and - /// the native macOS Settings menu). Brazilian Portuguese played this role while it was - /// menus-only. - fn core_menu_labels() -> Vec { - let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); - entries.into_iter().map(|(_, en, _)| en).collect() - } - - /// Brazilian Portuguese covers the whole interface (the Spanish catalog's entries and the native - /// macOS Settings menu), but, like Japanese and French, the tests require only the menus, so - /// strings added later show in English until they are translated. Every entry must still have a - /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. - #[test] - fn portuguese_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("pt-br.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Portuguese entry without a Spanish one: {ctx:?} {en:?}"); - } - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && Language::Es.has(text) && !Language::PtBr.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); - } - }; - for top in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - need(top); - } - for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); - } - } - for en in core_menu_labels() { - need(&en); - } - missing.sort(); - assert!(missing.is_empty(), "untranslated Portuguese menu labels: {missing:#?}"); - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, pt) in [ - ("File", "Arquivo"), - ("Settings", "Preferências"), - ("Gaussian Blur", "Desfoque gaussiano"), - ("Audio Track Mixer", "Misturador de faixas de áudio"), - ("Keyboard Shortcuts", "Atalhos de teclado"), - ("Playhead", "Cursor de reprodução"), - ] { - assert_eq!(Language::PtBr.tr(en), pt); - } - set_current(Language::PtBr); - assert!(matches_query("Gaussian Blur", "desfoque")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// Every `tl!`/`tlf!` literal outside test modules has a Spanish translation. - #[test] - fn spanish_translates_every_tl_literal() { - let mut missing = Vec::new(); + fn tl_literals_are_plain_strings() { for (path, text) in sources() { - for lit in tl_literals(&text) { - if !Language::Es.has(&lit) && !missing.contains(&lit) { - missing.push(lit); - } - } assert!(!text.contains("tl!(r") && !text.contains("tlf!(r"), "{path}: use a plain string literal in tl! and tlf!"); } - missing.sort(); - assert!(missing.is_empty(), "untranslated tl! strings: {missing:#?}"); } - /// The Simplified Chinese catalog is complete: every menu label and every `tl!` literal. - #[test] - fn chinese_translates_every_menu_label_and_tl_literal() { + /// Every string the interface translates: menu labels and paths (the native macOS app menu + /// included), `tl!`/`tlf!` literals, and names translated through `i18n::t`. + fn interface_strings() -> Vec { let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && !Language::ZhCn.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); - } - }; - for top in crate::menus::MENUS { - need(top); + let mut out = Vec::new(); + for top in crate::menus::MENUS.into_iter().chain(["Audio"]).chain(crate::menus::NATIVE_APP_MENU) { + push(&mut out, top); } for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); - } + push(&mut out, &it.label); + it.path.iter().for_each(|p| push(&mut out, p)); } for (_, text) in sources() { - for lit in tl_literals(&text) { - need(&lit); - } + tl_literals(&text).iter().for_each(|lit| push(&mut out, lit)); } - missing.sort(); - assert!(missing.is_empty(), "untranslated Chinese strings: {missing:#?}"); + dynamic_names().iter().for_each(|n| push(&mut out, n)); + out } - /// The Japanese catalog is complete: every menu label, every `tl!` literal and every name the - /// interface translates through `i18n::t`. + /// The missing-translations report. It always passes: a string a catalog lacks shows in + /// English. `cargo xtask ci` runs it with `--nocapture` so the list shows in the gate log + /// (`cargo xtask i18n-report` runs it alone). Per language it lists the interface strings the + /// catalog lacks, then the catalog entries whose source the interface no longer has (renamed + /// or removed strings, or names this scan doesn't enumerate). #[test] - fn japanese_translates_every_menu_label_tl_literal_and_name() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && !Language::Ja.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); + fn missing_translations_report() { + let strings = interface_strings(); + println!("Missing translations ({} interface strings; each shows in English):", strings.len()); + for l in Language::ALL { + let Some(text) = catalog_text(l) else { continue }; + let code = l.code(); + let mut missing: Vec<&str> = strings.iter().map(String::as_str).filter(|s| !l.has(s)).collect(); + missing.sort_unstable(); + println!("{code}: {} missing", missing.len()); + for s in missing { + println!(" {code}: {s:?}"); } - }; - for top in crate::menus::MENUS { - need(top); - } - for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); + let (entries, _) = catalog::parse_entries(text); + let unused: Vec<_> = entries.iter().filter(|(_, en, _)| translatable(en) && !strings.contains(en)).collect(); + if !unused.is_empty() { + println!("{code}: {} entries without a source", unused.len()); + for (ctx, en, _) in unused { + println!(" {code}: {ctx:?} {en:?}"); + } } } - for (_, text) in sources() { - for lit in tl_literals(&text) { - need(&lit); - } - } - for name in dynamic_names() { - need(&name); - } - missing.sort(); - assert!(missing.is_empty(), "untranslated Japanese strings: {missing:#?}"); } /// Chinese switches and persists like the other languages when a Chinese face is available, @@ -915,13 +778,6 @@ mod tests { out } - #[test] - fn spanish_translates_dynamic_names() { - let mut missing: Vec = dynamic_names().into_iter().filter(|n| !Language::Es.has(n)).collect(); - missing.sort(); - assert!(missing.is_empty(), "untranslated names: {missing:#?}"); - } - #[test] fn tl_literals_are_found_and_unescaped() { let src = "a(tl!(\"Save\")); b(tl!(\"Say \\\"hi\\\"\\n\")); tl!(x); tlf!(\"{n} file(s)\", n);"; @@ -976,7 +832,7 @@ mod tests { // the menus are drawn in German, the language names in their own language let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Speichern unter…")); + assert_eq!(label("file.saveAs"), Some(Language::De.tr("Save As…"))); assert_eq!(label("app.language.spanish"), Some("Español")); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains("\"language\":\"de\""), "{saved}"); @@ -995,7 +851,7 @@ mod tests { } let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Enregistrer sous…")); + assert_eq!(label("file.saveAs"), Some(Language::Fr.tr("Save As…"))); assert_eq!(label("app.language.german"), Some("Deutsch")); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains("\"language\":\"fr\""), "{saved}"); @@ -1012,7 +868,7 @@ mod tests { } let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Simpan Sebagai…")); + assert_eq!(label("file.saveAs"), Some(Language::Id.tr("Save As…"))); assert_eq!(label("app.language.french"), Some("Français")); assert_eq!(label("app.language.indonesian"), Some("Bahasa Indonesia")); let saved = serde_json::to_string(&app.ui).unwrap(); @@ -1030,7 +886,7 @@ mod tests { } let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Salva con nome…")); + assert_eq!(label("file.saveAs"), Some(Language::It.tr("Save As…"))); assert_eq!(label("app.language.indonesian"), Some("Bahasa Indonesia")); assert_eq!(label("app.language.italian"), Some("Italiano")); let saved = serde_json::to_string(&app.ui).unwrap(); @@ -1088,6 +944,9 @@ mod tests { assert_eq!(l(&[tag]), Language::It, "{tag}"); } assert_eq!(l(&["it-CH", "de-CH", "fr-CH", "en"]), Language::It); + for tag in ["ko", "ko-KR", "ko_KR.UTF-8"] { + assert_eq!(l(&[tag]), Language::Ko, "{tag}"); + } for none in [&["ita"][..], &["i"], &["-it"]] { assert_eq!(l(none), Language::En, "{none:?}"); } @@ -1138,199 +997,6 @@ mod tests { set_current(Language::En); } - #[test] - fn ukrainian_entries_cover_the_original_menu_catalog() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); - for (_, en, _) in entries { - assert!(known(&en) || en == "Settings", "not a menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert_ne!(Language::Uk.tr(en), en, "untranslated Ukrainian menu: {en}"); - } - } - - /// German covers the whole interface (every entry of the Spanish catalog, the native macOS app - /// menu included), but, like French, the tests require only the core menus and the native app - /// menu, so strings added later show in English until they are translated. Every entry must - /// still have a Spanish counterpart: a renamed or removed source would otherwise leave a dead - /// German entry. - #[test] - fn german_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("de.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && Language::Es.has(en), "German entry without a Spanish one: {ctx:?} {en:?}"); - } - for en in core_menu_labels() { - assert!(Language::De.has(&en), "missing German menu label: {en}"); - } - // "Clip" and "Audio" are the same word in German, so check for an entry, not a change - for en in crate::menus::MENUS.into_iter().chain(["Audio"]).chain(crate::menus::NATIVE_APP_MENU) { - assert!(Language::De.has(en), "untranslated German menu: {en}"); - } - assert_eq!(Language::De.tr("Settings"), "Voreinstellungen"); - assert_eq!(Language::De.tr("Quit FilmCraft"), "FilmCraft beenden"); - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, de) in [ - ("Gaussian Blur", "Gaußscher Weichzeichner"), - ("Audio Track Mixer", "Audiospur-Mischpult"), - ("Sequence", "Sequenz"), - ("Playhead", "Abspielposition"), - ("Keyboard Shortcuts", "Tastenkürzel"), - ("{n} min ago", "vor {n} Min."), - ] { - assert_eq!(Language::De.tr(en), de); - } - set_current(Language::De); - assert!(matches_query("Gaussian Blur", "weichzeichner")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// French covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu), but, like Japanese, the tests require only the core menus, so strings added - /// later show in English until they are translated. Every entry must still have a Spanish - /// counterpart: a renamed or removed source would otherwise leave a dead French entry. - #[test] - fn french_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("fr.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "French entry without a Spanish one: {ctx:?} {en:?}"); - } - for en in core_menu_labels() { - assert!(Language::Fr.has(&en), "missing French menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::Fr.has(en), "untranslated French menu: {en}"); - } - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, fr) in - [("Settings", "Préférences"), ("Gaussian Blur", "Flou gaussien"), ("Audio Track Mixer", "Mixeur de pistes audio"), ("Sequence", "Séquence")] - { - assert_eq!(Language::Fr.tr(en), fr); - } - set_current(Language::Fr); - assert!(matches_query("Gaussian Blur", "flou")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// Korean covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu); like French, the tests require the core menus, and every entry must still - /// have a Spanish counterpart so a renamed or removed source can't leave a dead Korean entry. - #[test] - fn korean_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("ko.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Korean entry without a Spanish one: {ctx:?} {en:?}"); - } - for en in core_menu_labels() { - assert!(Language::Ko.has(&en), "missing Korean menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::Ko.has(en), "untranslated Korean menu: {en}"); - } - for (en, ko) in [("File", "파일"), ("Edit", "편집"), ("Sequence", "시퀀스"), ("Settings", "설정")] { - assert_eq!(Language::Ko.tr(en), ko); - } - assert_eq!(Language::Ko.name(), "한국어"); - assert_eq!(Language::parse("ko"), Some(Language::Ko)); - for tag in ["ko", "ko-KR", "ko_KR.UTF-8"] { - assert_eq!(Language::from_locales(&[tag.to_string()]), Language::Ko, "{tag}"); - } - } - - #[test] - fn russian_entries_cover_the_original_menu_catalog() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - let (entries, _) = catalog::parse_entries(include_str!("ru.tsv")); - for (_, en, _) in entries { - assert!(known(&en) || en == "Settings", "not a menu label: {en}"); - } - for en in core_menu_labels() { - assert!(Language::Ru.has(&en), "missing Russian menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert_ne!(Language::Ru.tr(en), en, "untranslated Russian menu: {en}"); - } - } - - /// Indonesian covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu), but, like Japanese and French, the tests require only the core menus (the - /// menus-only Ukrainian catalog, the top-level menus and the native macOS Settings menu), so - /// strings added later show in English until they are translated. Every entry must still have a - /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. - #[test] - fn indonesian_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("id.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Indonesian entry without a Spanish one: {ctx:?} {en:?}"); - } - let (core, _) = catalog::parse_entries(include_str!("uk.tsv")); - for (_, en, _) in core { - assert!(Language::Id.has(&en), "missing Indonesian menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::Id.has(en), "untranslated Indonesian menu: {en}"); - } - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, id) in [ - ("Settings", "Preferensi"), - ("Gaussian Blur", "Buram Gaussian"), - ("Audio Track Mixer", "Mixer Trek Audio"), - ("Sequence", "Sekuens"), - ("Playhead", "Kepala Putar"), - ] { - assert_eq!(Language::Id.tr(en), id); - } - set_current(Language::Id); - assert!(matches_query("Gaussian Blur", "buram")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// Italian covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu); like Indonesian, the tests require only the core menus, so strings added - /// later show in English until they are translated, and every entry must have a Spanish - /// counterpart. - #[test] - fn italian_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("it.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Italian entry without a Spanish one: {ctx:?} {en:?}"); - } - let (core, _) = catalog::parse_entries(include_str!("uk.tsv")); - for (_, en, _) in core { - assert!(Language::It.has(&en), "missing Italian menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::It.has(en), "untranslated Italian menu: {en}"); - } - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, it) in [ - ("Settings", "Impostazioni"), - ("Gaussian Blur", "Sfocatura gaussiana"), - ("Audio Track Mixer", "Mixer tracce audio"), - ("Sequence", "Sequenza"), - ("Playhead", "Indicatore di riproduzione"), - ] { - assert_eq!(Language::It.tr(en), it); - } - set_current(Language::It); - assert!(matches_query("Gaussian Blur", "sfocatura")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - /// Every non-ASCII character of `text` renders with the bundled interface fonts, in every family. fn assert_bundled_fonts_cover(text: impl Iterator) { let ctx = egui::Context::default(); @@ -1439,9 +1105,11 @@ mod tests { output.textures_delta.clear(); ctx.fonts_mut(|fonts| { // every theme family falls back to the craft-fonts (when built with them; no system - // font is added then) or to the system Japanese font + // font is added then) or to the system Japanese font. Installed right-to-left faces + // (`rtl::install`) come after every other fallback by design, so they are left out. for family in crate::theme::font_families() { - let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default(); + let mut stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default(); + stack.retain(|n| !crate::rtl::is_system_face(n)); if craft { assert!(stack.last().is_some_and(|n| n.starts_with("craft:")), "{family:?}: {stack:?}"); assert!(!stack.iter().any(|n| n == JAPANESE_FONT), "{family:?}: {stack:?}"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c838e6fe9..14d66da4c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -232,6 +232,10 @@ pub struct Playback { /// The audio clock's last reading and when (egui time, s) it last moved: a device that stops /// consuming samples hands the clock back to the wall clock (see [`AUDIO_STALL_S`]). audio_seen: (u64, f64), + /// When playback lost its output (a failed open or a dead stream), when it is opened again + /// (egui time, s; 0 = not retrying). Pause/play proves a fresh stream works, so a stream the + /// device keeps dropping is reopened on a timer (see [`AUDIO_RETRY_S`]). + audio_retry_at: f64, /// Audio underruns for the current (or last) play (desktop: sound is mixed ahead). #[cfg(not(target_arch = "wasm32"))] pub audio_stats: std::sync::Arc, @@ -256,6 +260,11 @@ pub struct Playback { /// under this. pub const AUDIO_STALL_S: f64 = 1.0; +/// How often (s) playback re-opens the output after its stream or device failed, while playing +/// goes on silently on the wall clock. Reopening is what pause/play does by hand; doing it on a +/// timer brings the sound back when the device recovers (#2503). +pub const AUDIO_RETRY_S: f64 = 2.0; + /// How long `ui.screenshot` waits for the window to present the frame. const SCREENSHOT_TIMEOUT_S: f64 = 10.0; /// How long `ui.screenshot` waits for the UI to settle (monitors showing their exact frames, the @@ -770,7 +779,7 @@ impl FilmcraftApp { let now = ctx.input(|i| i.time); self.playback.anchor_time = now; self.playback.audio_seen = (0, now); - self.start_audio(); + self.start_audio(now); } if let Some(note) = self.audio.as_ref().and_then(|a| a.note()) { self.ui.status = note; @@ -1002,6 +1011,7 @@ impl FilmcraftApp { a.stop(); } self.playback.audio_clock = false; + self.playback.audio_retry_at = 0.0; } /// Start the clock (and audio) once the first frames are ready or the preroll timed out. @@ -1010,7 +1020,7 @@ impl FilmcraftApp { self.playback.anchor_time = now; self.playback.anchor_tick = self.session.playhead(); self.playback.audio_seen = (0, now); - self.start_audio(); + self.start_audio(now); // Audio Track Mixer: an automation pass runs while playing forward in real time if (self.playback.speed - 1.0).abs() < 1e-9 && !self.session.mixrec.active() { let t = self.session.playhead(); @@ -1062,6 +1072,7 @@ impl FilmcraftApp { } self.scrub.device_stopped(); self.playback.audio_clock = false; + self.playback.audio_retry_at = 0.0; if self.session.mixrec.active() { let t = self.session.playhead(); if let Err(e) = self.session.execute("mixer.recordStop", json!({"time": t.0})) { @@ -1072,7 +1083,7 @@ impl FilmcraftApp { panels::voiceover::on_stop(self); } - fn start_audio(&mut self) { + fn start_audio(&mut self, now: f64) { let Some(seq_id) = self.session.state.active_sequence else { return }; let start_tick = self.session.playhead(); let Some(a) = self.audio.as_mut() else { return }; @@ -1081,6 +1092,7 @@ impl FilmcraftApp { // either the original rate conversion or pitch-corrected program audio. let Some((mix_rate, mix)) = shuttle::mix(&self.session, seq_id, sr, self.playback.speed) else { a.stop(); + self.playback.audio_retry_at = 0.0; return; }; let cursor = start_tick.to_units_floor(i64::from(mix_rate)); @@ -1100,6 +1112,7 @@ impl FilmcraftApp { match a.start(fill) { Ok(_) => { self.playback.audio_clock = true; + self.playback.audio_retry_at = 0.0; if let Some(note) = self.audio.as_ref().and_then(|audio| audio.note()) { self.ui.status = note; } @@ -1107,6 +1120,7 @@ impl FilmcraftApp { Err(e) => { log::warn!("audio output unavailable: {e}"); self.playback.audio_clock = false; + self.playback.audio_retry_at = now + AUDIO_RETRY_S; } } } @@ -1129,6 +1143,15 @@ impl FilmcraftApp { if self.playback.anchor_time < 0.0 { self.playback.anchor_time = now; } + if !self.playback.audio_clock && self.playback.audio_retry_at > 0.0 && now >= self.playback.audio_retry_at { + // The output failed mid-play: open it again where the wall clock got to (what + // pause/play does by hand), so a recovering device brings the sound back. + self.playback.audio_retry_at = now + AUDIO_RETRY_S; + self.playback.anchor_tick = self.session.playhead(); + self.playback.anchor_time = now; + self.playback.audio_seen = (0, now); + self.start_audio(now); + } let rate = self.session.sequence_rate(); if self.playback.audio_clock && let Some(audio) = self.audio.as_mut() @@ -1142,6 +1165,7 @@ impl FilmcraftApp { self.playback.anchor_tick = self.session.playhead(); self.playback.anchor_time = now; self.playback.audio_clock = false; + self.playback.audio_retry_at = now + AUDIO_RETRY_S; if let Some(audio) = self.audio.as_mut() { audio.stop(); } @@ -1821,7 +1845,8 @@ impl FilmcraftApp { self.handle_drops(&ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { - if self.control_input_only { + // control-only mode ignores native menu commands, but Quit stays available (#1943) + if self.control_input_only && id != "app.quit" { continue; } if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { @@ -2477,7 +2502,7 @@ mod audio_recovery_tests { use super::*; use std::sync::{ Arc, - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }; struct Device { @@ -2724,6 +2749,74 @@ mod audio_recovery_tests { ); app.stop(); } + + /// A device whose stream dies mid-play (`ticking`) or whose open fails (`opens`). + struct Reviving { + starts: Arc, + opens: Arc, + ticking: Arc, + } + + impl AudioOut for Reviving { + fn start(&mut self, _: Box) -> Result { + self.starts.fetch_add(1, Ordering::Relaxed); + if self.opens.load(Ordering::Relaxed) { Ok(48_000) } else { Err("device unavailable".into()) } + } + fn stop(&mut self) {} + fn sample_rate(&self) -> u32 { + 48_000 + } + fn played_frames(&self) -> Option { + self.ticking.load(Ordering::Relaxed).then_some(0) + } + } + + #[test] + fn a_lost_output_is_reopened_and_the_sound_returns() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(session); + let starts = Arc::new(AtomicUsize::new(0)); + let ticking = Arc::new(AtomicBool::new(true)); + app.audio = Some(Box::new(Reviving { starts: starts.clone(), opens: Arc::new(AtomicBool::new(true)), ticking: ticking.clone() })); + app.play(1.0); + app.end_preroll(0.0); + assert!(app.playback.audio_clock && starts.load(Ordering::Relaxed) == 1); + // the stream dies: the device stops reporting a playback clock + ticking.store(false, Ordering::Relaxed); + let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); + output.textures_delta.clear(); + assert!(!app.playback.audio_clock); + // the device recovers before the next retry: playback reopens it itself + ticking.store(true, Ordering::Relaxed); + let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0 + AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); + output.textures_delta.clear(); + assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is reopened without pause/play"); + assert!(app.playback.audio_clock, "the fresh stream is the master clock again"); + app.stop(); + } + + #[test] + fn a_device_that_appears_mid_playback_starts_sounding() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(session); + let starts = Arc::new(AtomicUsize::new(0)); + let opens = Arc::new(AtomicBool::new(false)); + app.audio = Some(Box::new(Reviving { starts: starts.clone(), opens: opens.clone(), ticking: Arc::new(AtomicBool::new(true)) })); + app.play(1.0); + app.end_preroll(0.0); + assert!(!app.playback.audio_clock, "no device yet: playing on the wall clock"); + // headphones plug in while playing: the next retry opens them + opens.store(true, Ordering::Relaxed); + let mut output = ctx.run_ui(egui::RawInput { time: Some(AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); + output.textures_delta.clear(); + assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is retried without pause/play"); + assert!(app.playback.audio_clock); + app.stop(); + } } /// Whether a shortcut chord must be left alone because a text field is focused. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1f37d2804..6595e8b3b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -908,7 +908,8 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let labels = app.session.prefs.labels.clone(); let ctx = ui.ctx().clone(); let mut clicked: Option = None; - egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| { + // The bar's titles and their menus share one size and colour, as in a native menu bar. + egui::MenuBar::new().style(crate::theme::menu_style).config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| { let mut titles = Vec::with_capacity(MENUS.len()); for top in MENUS { let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 9dac65003..f1cf5224d 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -401,6 +401,23 @@ fn draft_text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, stored: &str, hint: &s (r.changed() && !v.trim().is_empty()).then_some(v) } +/// Longest folder tail the link lays out or shows on hover. +const MAX_LOCATION_DISPLAY_BYTES: usize = 4096; + +/// Keep the tail of a UTF-8 folder name without copying or splitting a codepoint. Project item +/// names can enter the generated export path, so UI display work must not scale with an arbitrary +/// imported name. +fn location_tail(s: &str) -> (&str, bool) { + if s.len() <= MAX_LOCATION_DISPLAY_BYTES { + return (s, false); + } + let mut start = s.len() - MAX_LOCATION_DISPLAY_BYTES; + while !s.is_char_boundary(start) { + start += 1; + } + (&s[start..], true) +} + /// The output folder as a link (folder icon, accent colour, underlined); the caller opens the save /// dialog on a click. A path too long for the row loses its start, so the folder's name stays. fn location_link(ui: &mut egui::Ui, t: &Tokens, folder: &str) -> egui::Response { @@ -408,18 +425,34 @@ fn location_link(ui: &mut egui::Ui, t: &Tokens, folder: &str) -> egui::Response let col = if resp.hovered() { t.accent_hover } else { t.accent }; crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 8.0, r.center().y), vec2(14.0, 14.0)), crate::icons::Icon::Folder, col); let room = (r.width() - 22.0).max(0.0); + let (folder, truncated) = location_tail(folder); let mut galley = ui.painter().layout_no_wrap(folder.to_string(), Tokens::ui(12.5), col); - for (i, _) in folder.char_indices().skip(1) { - if galley.size().x <= room { - break; - } - galley = ui.painter().layout_no_wrap(format!("…{}", folder.get(i..).unwrap_or("")), Tokens::ui(12.5), col); + if galley.size().x > room { + let starts: Vec = folder.char_indices().map(|(i, _)| i).collect(); + let suffix = if starts.len() > 1 { + let mut lo = 1usize; + let mut hi = starts.len() - 1; + while lo < hi { + let mid = (lo + hi) / 2; + let candidate = format!("…{}", &folder[starts[mid]..]); + if ui.painter().layout_no_wrap(candidate, Tokens::ui(12.5), col).size().x <= room { + hi = mid; + } else { + lo = mid + 1; + } + } + format!("…{}", &folder[starts[lo]..]) + } else { + "…".into() + }; + galley = ui.painter().layout_no_wrap(suffix, Tokens::ui(12.5), col); } let at = pos2(r.min.x + 20.0, r.center().y - galley.size().y / 2.0); let underline = at.y + galley.size().y; ui.painter().hline(at.x..=at.x + galley.size().x, underline, egui::Stroke::new(1.0, col)); ui.painter().galley(at, galley, col); - resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(folder) + let hover = if truncated { format!("…{folder}") } else { folder.to_string() }; + resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(hover) } const RATES: [(&str, FrameRate); 11] = [ @@ -1381,7 +1414,9 @@ fn quick_file(app: &FilmcraftApp, path: &str, seq_name: &str, ext: &str) -> Opti return None; } let file = match name { - None => format!("{seq_name}.{ext}"), + // Sequence names come from the project, not from this Quick Export interaction. Keep + // metadata in one filename component before joining it to the selected/default folder. + None => format!("{}.{}", filmcraft_engine::export_tools::file_safe(seq_name), ext), Some(n) if cur.eq_ignore_ascii_case(ext) => n.to_string(), Some(n) if !cur.is_empty() && app.session.export_presets.all().iter().any(|x| x.settings.extension().eq_ignore_ascii_case(cur)) => { std::path::Path::new(n).with_extension(ext).to_string_lossy().to_string() @@ -1504,3 +1539,18 @@ fn list_row(ui: &mut egui::Ui, t: &Tokens, selected: bool, text: &str, width: f3 ui.painter().text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.5), if selected { Color32::WHITE } else { t.text }); resp } + +#[cfg(test)] +mod location_tests { + use super::*; + + #[test] + fn location_tail_is_bounded_and_keeps_utf8_intact() { + let input = format!("/{}終", "é".repeat(MAX_LOCATION_DISPLAY_BYTES)); + let (tail, truncated) = location_tail(&input); + assert!(truncated); + assert!(tail.len() <= MAX_LOCATION_DISPLAY_BYTES); + assert!(tail.ends_with('終')); + assert!(std::str::from_utf8(tail.as_bytes()).is_ok()); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ff18ab0f2..0f21158fc 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -180,6 +180,14 @@ pub fn dragged_project_item(ui: &egui::Ui) -> Option { _ => None, } } +/// An item dragged from the Project panel (or the Media Browser), but not a Source Monitor drag, +/// whose In/Out range and video/audio choice only a timeline drop keeps. +pub fn dragged_panel_item(ui: &egui::Ui) -> Option { + match payload(ui) { + Some(DragPayload::Item(i)) => Some(i), + _ => None, + } +} /// The items a drag from the Project panel carries: the whole selection when the dragged item is /// one of several selected, otherwise just the dragged item. pub fn dragged_selection(app: &FilmcraftApp, item: ItemId) -> Vec { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index e6d95c0e8..6e3487e8d 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -630,6 +630,17 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action } icons::paint(ui.painter(), r.shrink(5.0), icon, t.icon); if id == "new-item" { + // a clip dropped here makes a sequence that matches it, as in Premiere (#2855) + if let Some(item) = crate::panels::dragged_panel_item(ui) + && ui.rect_contains_pointer(r) + { + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, app.tokens.accent), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + let items: Vec = crate::panels::dragged_selection(app, item).iter().map(|i| i.0).collect(); + actions.push(("file.newSequenceFromClip".into(), json!({"items": items}))); + crate::panels::clear_drag(ui); + } + } egui::Popup::menu(&resp).show(|ui| new_item_menu(app, ui, pre, actions)); } else if resp.clicked() { match id { diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs index db5fab592..62c50ddf3 100644 --- a/crates/ui-egui/src/panels/shortcuts_dialog.rs +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -822,13 +822,17 @@ mod localization_tests { use super::*; #[test] - fn command_search_accepts_spanish_labels_ids_and_keys() { - crate::i18n::set_current(crate::i18n::Language::Es); + fn command_search_accepts_translated_labels_ids_and_keys() { let row = json!({"id": "edit.undo", "label": "Undo", "category": "Edit", "shortcuts": [{"keys": "Cmd+Z", "display": "Ctrl+Z"}]}); - for query in ["deshacer", "undo", "edit.undo", "ctrl+z", "cmd+z"] { - assert!(matches_search(&row, query), "{query}"); + for l in crate::i18n::Language::ALL { + crate::i18n::set_current(l); + let undo = l.tr("Undo").to_lowercase(); + for query in [undo.as_str(), "undo", "edit.undo", "ctrl+z", "cmd+z"] { + assert!(matches_search(&row, query), "{}: {query}", l.code()); + } + let redo = l.tr("Redo").to_lowercase(); + assert!(!matches_search(&row, &redo), "{}: {redo}", l.code()); } - assert!(!matches_search(&row, "rehacer")); crate::i18n::set_current(crate::i18n::Language::En); } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a73611886..33c243762 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2861,76 +2861,80 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } resp.context_menu(|ui| { - ui.set_min_width(220.0); - if let Some(marker) = ctx.data(|d| d.get_temp::>(marker_menu_id)).flatten() { - marker_menu(app, &ctx, ui, marker); - return; - } - if let Some(target) = ctx.data(|d| d.get_temp::>(edit_point_menu_id)).flatten() { - edit_point_menu(app, &ctx, ui, seq, target); - return; - } - if ctx.data(|d| d.get_temp::(gap_menu_id)).unwrap_or(false) { - gap_menu(app, &ctx, ui); - return; - } - let sel = app.session.state.selection.clone(); - let picked: Vec<&filmcraft_project::TrackItem> = sel.iter().filter_map(|c| seq.find_item(*c).map(|(_, it)| it)).collect(); - let all_enabled = !picked.is_empty() && picked.iter().all(|it| it.enabled); - let linked = picked.iter().any(|it| it.link.is_some()); - let mut first = true; - for group in CLIP_MENU { - if !std::mem::take(&mut first) { - ui.separator(); + // a long menu scrolls instead of running off the bottom of the screen (#2852) + let max_height = (ui.ctx().content_rect().height() - 16.0).max(0.0); + egui::ScrollArea::vertical().id_salt("clip-menu-scroll").max_height(max_height).min_scrolled_height(max_height).show(ui, |ui| { + ui.set_min_width(220.0); + if let Some(marker) = ctx.data(|d| d.get_temp::>(marker_menu_id)).flatten() { + marker_menu(app, &ctx, ui, marker); + return; } - for &(label, cmd) in *group { - if cmd == "edit.label" { - ui.add_enabled_ui(!picked.is_empty(), |ui| { - let menu = ui.menu_button(crate::i18n::t(label), |ui| { - // the user's label names and colours, as in Edit ▸ Label (#786) - let labels = app.session.prefs.labels.clone(); - for l in filmcraft_project::Label::ALL { - if crate::menus::label_choice(ui, &mut app.auto, &labels, l, "timeline.clipMenu.label").clicked() { - if let Err(error) = app.session.execute("edit.label", json!({"label":l.name()})) { - app.ui.status = error.to_string(); + if let Some(target) = ctx.data(|d| d.get_temp::>(edit_point_menu_id)).flatten() { + edit_point_menu(app, &ctx, ui, seq, target); + return; + } + if ctx.data(|d| d.get_temp::(gap_menu_id)).unwrap_or(false) { + gap_menu(app, &ctx, ui); + return; + } + let sel = app.session.state.selection.clone(); + let picked: Vec<&filmcraft_project::TrackItem> = sel.iter().filter_map(|c| seq.find_item(*c).map(|(_, it)| it)).collect(); + let all_enabled = !picked.is_empty() && picked.iter().all(|it| it.enabled); + let linked = picked.iter().any(|it| it.link.is_some()); + let mut first = true; + for group in CLIP_MENU { + if !std::mem::take(&mut first) { + ui.separator(); + } + for &(label, cmd) in *group { + if cmd == "edit.label" { + ui.add_enabled_ui(!picked.is_empty(), |ui| { + let menu = ui.menu_button(crate::i18n::t(label), |ui| { + // the user's label names and colours, as in Edit ▸ Label (#786) + let labels = app.session.prefs.labels.clone(); + for l in filmcraft_project::Label::ALL { + if crate::menus::label_choice(ui, &mut app.auto, &labels, l, "timeline.clipMenu.label").clicked() { + if let Err(error) = app.session.execute("edit.label", json!({"label":l.name()})) { + app.ui.status = error.to_string(); + } + ui.close(); } - ui.close(); + } + }); + app.auto.add_response("timeline.clipMenu.edit.label", &menu.response, crate::i18n::t(label)); + }); + continue; + } + if cmd == "clip.multicam" { + multicam_menu(app, ui, &picked); + continue; + } + let label = match cmd { + "clip.enable" if all_enabled => tl!("✓ Enable"), + "clip.link" if linked => tl!("Unlink"), + _ => crate::i18n::t(label), + }; + let r = ui.add_enabled(!sel.is_empty() && app.session.is_enabled(cmd), egui::Button::new(label)); + app.auto.add(&format!("timeline.clipMenu.{cmd}"), r.rect, label); + if r.clicked() { + match crate::menus::invoke(app, &ctx, cmd, json!({})) { + // Unlink (#220): the clips were selected together because they were linked; + // keep only the one right-clicked, so it can be dragged away from the others + Ok(v) if cmd == "clip.link" && v["linked"] == json!(false) => { + if let Some(clip) = ctx.data(|d| d.get_temp::(menu_clip_id)) + && app.session.state.selection.contains(&clip) + { + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); } } - }); - app.auto.add_response("timeline.clipMenu.edit.label", &menu.response, crate::i18n::t(label)); - }); - continue; - } - if cmd == "clip.multicam" { - multicam_menu(app, ui, &picked); - continue; - } - let label = match cmd { - "clip.enable" if all_enabled => tl!("✓ Enable"), - "clip.link" if linked => tl!("Unlink"), - _ => crate::i18n::t(label), - }; - let r = ui.add_enabled(!sel.is_empty() && app.session.is_enabled(cmd), egui::Button::new(label)); - app.auto.add(&format!("timeline.clipMenu.{cmd}"), r.rect, label); - if r.clicked() { - match crate::menus::invoke(app, &ctx, cmd, json!({})) { - // Unlink (#220): the clips were selected together because they were linked; - // keep only the one right-clicked, so it can be dragged away from the others - Ok(v) if cmd == "clip.link" && v["linked"] == json!(false) => { - if let Some(clip) = ctx.data(|d| d.get_temp::(menu_clip_id)) - && app.session.state.selection.contains(&clip) - { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); - } + Ok(_) => {} + Err(e) => app.ui.status = e, } - Ok(_) => {} - Err(e) => app.ui.status = e, + ui.close(); } - ui.close(); } } - } + }); }); // ---- drops: project items and effects diff --git a/crates/ui-egui/src/source_playback.rs b/crates/ui-egui/src/source_playback.rs index 2a783325d..df1054f69 100644 --- a/crates/ui-egui/src/source_playback.rs +++ b/crates/ui-egui/src/source_playback.rs @@ -8,7 +8,7 @@ use filmcraft_render::SourceProvider; use filmcraft_time::Tick; use serde_json::json; -use crate::{AUDIO_STALL_S, FilmcraftApp, Playback}; +use crate::{AUDIO_RETRY_S, AUDIO_STALL_S, FilmcraftApp, Playback}; #[derive(Default)] pub struct SourcePlayback { @@ -96,7 +96,7 @@ impl FilmcraftApp { self.source_playback.shown = time; self.source_playback.clock = Playback { playing: true, speed, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() }; self.source_playback.clock.meter.start(speed); - self.start_source_audio(); + self.start_source_audio(0.0); Ok(()) } @@ -113,18 +113,20 @@ impl FilmcraftApp { } } - fn start_source_audio(&mut self) { + fn start_source_audio(&mut self, now: f64) { self.source_playback.clock.audio_clock = false; if (self.source_playback.clock.speed - 1.0).abs() > 1e-9 { if let Some(audio) = self.audio.as_mut() { audio.stop(); } + self.source_playback.clock.audio_retry_at = 0.0; return; } let Some(item) = self.source_playback.item else { return }; let Some(view) = source_view(&self.session, item) else { return }; let Some(media) = self.session.project.item(view.media).and_then(|i| i.as_media()) else { return }; if !media.info.has_audio() { + self.source_playback.clock.audio_retry_at = 0.0; return; } let project = self.session.project.clone(); @@ -185,8 +187,14 @@ impl FilmcraftApp { }) }; match audio.start(fill) { - Ok(_) => self.source_playback.clock.audio_clock = true, - Err(e) => self.ui.status = format!("Source audio output failed: {e}"), + Ok(_) => { + self.source_playback.clock.audio_clock = true; + self.source_playback.clock.audio_retry_at = 0.0; + } + Err(e) => { + self.ui.status = format!("Source audio output failed: {e}"); + self.source_playback.clock.audio_retry_at = now + AUDIO_RETRY_S; + } } } @@ -211,7 +219,16 @@ impl FilmcraftApp { self.source_playback.clock.anchor_tick = time; self.source_playback.clock.anchor_time = now; self.source_playback.clock.audio_seen = (0, now); - self.start_source_audio(); + self.start_source_audio(now); + } + if !self.source_playback.clock.audio_clock && self.source_playback.clock.audio_retry_at > 0.0 && now >= self.source_playback.clock.audio_retry_at { + // The output failed mid-play: open it again where the wall clock got to, so a + // recovering device brings the sound back without a pause/play. + self.source_playback.clock.audio_retry_at = now + AUDIO_RETRY_S; + self.source_playback.clock.anchor_tick = time; + self.source_playback.clock.anchor_time = now; + self.source_playback.clock.audio_seen = (0, now); + self.start_source_audio(now); } if self.source_playback.clock.audio_clock && let Some(audio) = self.audio.as_mut() @@ -233,6 +250,10 @@ impl FilmcraftApp { clock.anchor_tick = time; clock.anchor_time = now; clock.audio_clock = false; + // A dead stream is reopened on a timer; a stalled one keeps the wall clock (#136). + if reading.is_none() { + clock.audio_retry_at = now + AUDIO_RETRY_S; + } if let Some(audio) = self.audio.as_mut() { audio.stop(); } @@ -275,7 +296,7 @@ impl FilmcraftApp { self.source_playback.clock.anchor_tick = time; self.source_playback.clock.anchor_time = now; self.source_playback.clock.audio_seen = (0, now); - self.start_source_audio(); + self.start_source_audio(now); ctx.request_repaint(); return; } diff --git a/crates/ui-egui/tests/audio51_ui.rs b/crates/ui-egui/tests/it/audio51_ui.rs similarity index 100% rename from crates/ui-egui/tests/audio51_ui.rs rename to crates/ui-egui/tests/it/audio51_ui.rs diff --git a/crates/ui-egui/tests/audio_fx_editor_ui.rs b/crates/ui-egui/tests/it/audio_fx_editor_ui.rs similarity index 100% rename from crates/ui-egui/tests/audio_fx_editor_ui.rs rename to crates/ui-egui/tests/it/audio_fx_editor_ui.rs diff --git a/crates/ui-egui/tests/caption_properties_ui.rs b/crates/ui-egui/tests/it/caption_properties_ui.rs similarity index 100% rename from crates/ui-egui/tests/caption_properties_ui.rs rename to crates/ui-egui/tests/it/caption_properties_ui.rs diff --git a/crates/ui-egui/tests/captions_font_ui.rs b/crates/ui-egui/tests/it/captions_font_ui.rs similarity index 64% rename from crates/ui-egui/tests/captions_font_ui.rs rename to crates/ui-egui/tests/it/captions_font_ui.rs index bd6822d55..877976c74 100644 --- a/crates/ui-egui/tests/captions_font_ui.rs +++ b/crates/ui-egui/tests/it/captions_font_ui.rs @@ -93,3 +93,52 @@ fn caption_font_picker_sets_the_track_font() { d.frames(3); assert_eq!(d.font(track), "Noto Serif"); } + +/// Every text shape on screen, with the colour of its first run. +fn painted_text(d: &Driver) -> Vec<(String, egui::Color32)> { + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Color32)>) { + match s { + egui::Shape::Text(t) => { + if let Some(sec) = t.galley.job.sections.first() { + out.push((t.galley.text().to_string(), sec.format.color)); + } + } + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + d.harness.output().shapes.iter().for_each(|c| walk(&c.shape, &mut out)); + out +} + +/// Relative luminance (WCAG) of an opaque colour. +fn luminance(c: egui::Color32) -> f32 { + let f = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * f(c.r()) + 0.7152 * f(c.g()) + 0.0722 * f(c.b()) +} + +// Regression test for #1072: the Captions tab's "Import captions file…" button label was always +// white, so it vanished against the light grey button in light mode. +#[test] +fn import_captions_button_label_is_legible_in_both_themes() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Captions and Graphics"})); + d.ok("ui.click", json!({"id": "text.tab.Captions"})); + for theme in ["dark", "light"] { + d.ok("ui.set", json!({"theme": theme})); + d.frames(3); + assert!(d.ids("text.captions.import").iter().any(|i| i == "text.captions.import"), "{theme}: the empty Captions tab offers the import button"); + let field_bg = d.harness.state().tokens.field_bg; + let (_, color) = painted_text(&d) + .into_iter() + .find(|(text, _)| text.starts_with("Import captions file")) + .unwrap_or_else(|| panic!("{theme}: the button label is not drawn")); + let (hi, lo) = (luminance(color).max(luminance(field_bg)), luminance(color).min(luminance(field_bg))); + let contrast = (hi + 0.05) / (lo + 0.05); + assert!(contrast >= 4.5, "{theme}: label {color:?} on {field_bg:?} has contrast {contrast:.2}:1"); + } +} diff --git a/crates/ui-egui/tests/chrome_ui.rs b/crates/ui-egui/tests/it/chrome_ui.rs similarity index 100% rename from crates/ui-egui/tests/chrome_ui.rs rename to crates/ui-egui/tests/it/chrome_ui.rs diff --git a/crates/ui-egui/tests/clip_audio_ui.rs b/crates/ui-egui/tests/it/clip_audio_ui.rs similarity index 100% rename from crates/ui-egui/tests/clip_audio_ui.rs rename to crates/ui-egui/tests/it/clip_audio_ui.rs diff --git a/crates/ui-egui/tests/clip_dialogs_ui.rs b/crates/ui-egui/tests/it/clip_dialogs_ui.rs similarity index 95% rename from crates/ui-egui/tests/clip_dialogs_ui.rs rename to crates/ui-egui/tests/it/clip_dialogs_ui.rs index 72c947fb0..2d9bbf20f 100644 --- a/crates/ui-egui/tests/clip_dialogs_ui.rs +++ b/crates/ui-egui/tests/it/clip_dialogs_ui.rs @@ -347,6 +347,26 @@ fn quitting_with_unsaved_changes_asks_to_save() { assert!(!d.sent(&egui::ViewportCommand::CancelClose), "the confirmed quit goes through"); } +/// #1943: the macOS menu bar's Quit (Cmd+Q) sends `app.quit` to the command inbox; with unsaved +/// changes that closes the window through the prompt instead of quitting outright. +#[test] +fn native_menu_quit_with_unsaved_changes_asks_to_save() { + let mut d = Driver::new(); + d.edit(); + let (tx, rx) = channel(); + d.app().command_inbox = Some(rx); + tx.send("app.quit".to_string()).unwrap(); + d.frames(1); + assert!(d.sent(&egui::ViewportCommand::Close), "Quit asks the window to close"); + assert!(!d.app().quit_confirmed, "nothing is confirmed yet"); + // the window's close request is cancelled and the prompt asks instead + d.request_close(); + assert!(d.sent(&egui::ViewportCommand::CancelClose), "the window must stay open"); + d.frames(2); + assert!(d.has("quit.save") && d.has("quit.dontSave") && d.has("quit.cancel")); + assert!(d.app().session.is_dirty()); +} + #[test] fn quit_save_on_a_new_project_uses_the_save_dialog_and_scripted_quit_does_not_ask() { let path = std::env::temp_dir().join(format!("fc-quit-save-{}.fcproj", std::process::id())); diff --git a/crates/ui-egui/tests/clipboard_paste_ui.rs b/crates/ui-egui/tests/it/clipboard_paste_ui.rs similarity index 100% rename from crates/ui-egui/tests/clipboard_paste_ui.rs rename to crates/ui-egui/tests/it/clipboard_paste_ui.rs diff --git a/crates/ui-egui/tests/color_ui.rs b/crates/ui-egui/tests/it/color_ui.rs similarity index 100% rename from crates/ui-egui/tests/color_ui.rs rename to crates/ui-egui/tests/it/color_ui.rs diff --git a/crates/ui-egui/tests/delete_caption_tracks_ui.rs b/crates/ui-egui/tests/it/delete_caption_tracks_ui.rs similarity index 100% rename from crates/ui-egui/tests/delete_caption_tracks_ui.rs rename to crates/ui-egui/tests/it/delete_caption_tracks_ui.rs diff --git a/crates/ui-egui/tests/deselect_ui.rs b/crates/ui-egui/tests/it/deselect_ui.rs similarity index 100% rename from crates/ui-egui/tests/deselect_ui.rs rename to crates/ui-egui/tests/it/deselect_ui.rs diff --git a/crates/ui-egui/tests/dialog_buttons_ui.rs b/crates/ui-egui/tests/it/dialog_buttons_ui.rs similarity index 100% rename from crates/ui-egui/tests/dialog_buttons_ui.rs rename to crates/ui-egui/tests/it/dialog_buttons_ui.rs diff --git a/crates/ui-egui/tests/edit_point_menu_ui.rs b/crates/ui-egui/tests/it/edit_point_menu_ui.rs similarity index 84% rename from crates/ui-egui/tests/edit_point_menu_ui.rs rename to crates/ui-egui/tests/it/edit_point_menu_ui.rs index d4e259b50..afe545975 100644 --- a/crates/ui-egui/tests/edit_point_menu_ui.rs +++ b/crates/ui-egui/tests/it/edit_point_menu_ui.rs @@ -18,11 +18,15 @@ struct Driver { impl Driver { fn new() -> Self { + Self::with_height(980.0) + } + + fn with_height(height: f32) -> Self { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); let (tx, rx) = channel(); let app = FilmcraftApp::new(s).with_control(rx); - let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let harness = Harness::builder().with_size(egui::vec2(1600.0, height)).with_max_steps(10_000).build_eframe(move |_cc| app); let mut d = Driver { harness, tx }; d.frames(4); d @@ -66,6 +70,14 @@ impl Driver { v.as_array().unwrap().iter().any(|e| e["id"] == json!(id)) } + /// [x, y, width, height] + fn rect(&mut self, id: &str) -> [f64; 4] { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + [r[0], r[1], r[2], r[3]] + } + fn seq(&mut self) -> Sequence { self.harness.state_mut().session.active_sequence().unwrap().clone() } @@ -158,3 +170,18 @@ fn join_through_edits_joins_only_the_right_clicked_cut() { d.click("timeline.editPointMenu.joinThroughEdits"); assert_eq!(d.seq().video_tracks[0].items.len(), before, "disabled: nothing joined"); } + +/// #2852: on a short screen the clip menu scrolls, so the items past its bottom can be reached. +#[test] +fn the_clip_menu_scrolls_on_a_short_screen() { + let height = 600.0; + let mut d = Driver::with_height(height as f32); + let (_, b) = d.cut_on_v1(); + d.right_click(b, 0.5); + let last = "timeline.clipMenu.sequence.joinThroughEdits"; + assert!(d.rect(last)[1] > height, "the menu is taller than the screen"); + d.ok("ui.scroll", json!({"id": "timeline.clipMenu.edit.cut", "dy": -2000.0})); + d.frames(10); + let r = d.rect(last); + assert!(r[1] >= 0.0 && r[1] + r[3] <= height, "Join Through Edits scrolled into view: {r:?}"); +} diff --git a/crates/ui-egui/tests/escape_drag_ui.rs b/crates/ui-egui/tests/it/escape_drag_ui.rs similarity index 100% rename from crates/ui-egui/tests/escape_drag_ui.rs rename to crates/ui-egui/tests/it/escape_drag_ui.rs diff --git a/crates/ui-egui/tests/essential_sound_ui.rs b/crates/ui-egui/tests/it/essential_sound_ui.rs similarity index 100% rename from crates/ui-egui/tests/essential_sound_ui.rs rename to crates/ui-egui/tests/it/essential_sound_ui.rs diff --git a/crates/ui-egui/tests/export_menu_ui.rs b/crates/ui-egui/tests/it/export_menu_ui.rs similarity index 100% rename from crates/ui-egui/tests/export_menu_ui.rs rename to crates/ui-egui/tests/it/export_menu_ui.rs diff --git a/crates/ui-egui/tests/export_ui.rs b/crates/ui-egui/tests/it/export_ui.rs similarity index 100% rename from crates/ui-egui/tests/export_ui.rs rename to crates/ui-egui/tests/it/export_ui.rs diff --git a/crates/ui-egui/tests/frame_export_ui.rs b/crates/ui-egui/tests/it/frame_export_ui.rs similarity index 100% rename from crates/ui-egui/tests/frame_export_ui.rs rename to crates/ui-egui/tests/it/frame_export_ui.rs diff --git a/crates/ui-egui/tests/graphics_handles_ui.rs b/crates/ui-egui/tests/it/graphics_handles_ui.rs similarity index 100% rename from crates/ui-egui/tests/graphics_handles_ui.rs rename to crates/ui-egui/tests/it/graphics_handles_ui.rs diff --git a/crates/ui-egui/tests/graphics_notice_ui.rs b/crates/ui-egui/tests/it/graphics_notice_ui.rs similarity index 93% rename from crates/ui-egui/tests/graphics_notice_ui.rs rename to crates/ui-egui/tests/it/graphics_notice_ui.rs index bd743ef17..e8805dc58 100644 --- a/crates/ui-egui/tests/graphics_notice_ui.rs +++ b/crates/ui-egui/tests/it/graphics_notice_ui.rs @@ -115,9 +115,11 @@ fn a_reset_that_cannot_be_saved_says_why_and_keeps_the_button() { fn the_notice_follows_the_interface_language() { let notice = GraphicsNotice::new("software rendering", Box::new(|| Ok(()))); let mut d = Driver::new(Some(notice)); - d.app().ui.language = filmcraft_ui_egui::i18n::Language::Es; + let es = filmcraft_ui_egui::i18n::Language::Es; + d.app().ui.language = es; d.frames(3); - assert_eq!(d.label("graphics.notice"), "Modo gráfico seguro: procesamiento por software"); - assert_eq!(d.label("graphics.notice.reset"), "Usar gráficos predeterminados"); + let title = filmcraft_ui_egui::i18n::fmt(es.tr("Safe graphics mode: {mode}"), &[("mode", es.tr("software rendering"))]); + assert_eq!(d.label("graphics.notice"), title); + assert_eq!(d.label("graphics.notice.reset"), es.tr("Use Default Graphics")); filmcraft_ui_egui::i18n::set_current(filmcraft_ui_egui::i18n::Language::En); } diff --git a/crates/ui-egui/tests/graphics_templates_ui.rs b/crates/ui-egui/tests/it/graphics_templates_ui.rs similarity index 100% rename from crates/ui-egui/tests/graphics_templates_ui.rs rename to crates/ui-egui/tests/it/graphics_templates_ui.rs diff --git a/crates/ui-egui/tests/graphics_ui.rs b/crates/ui-egui/tests/it/graphics_ui.rs similarity index 100% rename from crates/ui-egui/tests/graphics_ui.rs rename to crates/ui-egui/tests/it/graphics_ui.rs diff --git a/crates/ui-egui/tests/interchange_export_ui.rs b/crates/ui-egui/tests/it/interchange_export_ui.rs similarity index 100% rename from crates/ui-egui/tests/interchange_export_ui.rs rename to crates/ui-egui/tests/it/interchange_export_ui.rs diff --git a/crates/ui-egui/tests/interface_zoom_ui.rs b/crates/ui-egui/tests/it/interface_zoom_ui.rs similarity index 100% rename from crates/ui-egui/tests/interface_zoom_ui.rs rename to crates/ui-egui/tests/it/interface_zoom_ui.rs diff --git a/crates/ui-egui/tests/keyboard_ui.rs b/crates/ui-egui/tests/it/keyboard_ui.rs similarity index 100% rename from crates/ui-egui/tests/keyboard_ui.rs rename to crates/ui-egui/tests/it/keyboard_ui.rs diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/it/keyframes_ui.rs similarity index 100% rename from crates/ui-egui/tests/keyframes_ui.rs rename to crates/ui-egui/tests/it/keyframes_ui.rs diff --git a/crates/ui-egui/tests/links_ui.rs b/crates/ui-egui/tests/it/links_ui.rs similarity index 100% rename from crates/ui-egui/tests/links_ui.rs rename to crates/ui-egui/tests/it/links_ui.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 000000000..c5a3437d2 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,80 @@ +mod audio51_ui; +mod audio_fx_editor_ui; +mod caption_properties_ui; +mod captions_font_ui; +mod chrome_ui; +mod clip_audio_ui; +mod clip_dialogs_ui; +mod clipboard_paste_ui; +mod color_ui; +mod delete_caption_tracks_ui; +mod deselect_ui; +mod dialog_buttons_ui; +mod edit_point_menu_ui; +mod escape_drag_ui; +mod essential_sound_ui; +mod export_menu_ui; +mod export_ui; +mod frame_export_ui; +mod graphics_handles_ui; +mod graphics_notice_ui; +mod graphics_templates_ui; +mod graphics_ui; +mod interchange_export_ui; +mod interface_zoom_ui; +mod keyboard_ui; +mod keyframes_ui; +mod links_ui; +mod marked_playback_position; +mod mask_roundness_ui; +mod masks_ui; +mod media_ui; +mod menus_ui; +mod meters_ui; +mod mixer_ui; +mod monitor_geometry; +mod multicam_ui; +mod native_dialogs_ui; +mod nest_editing_ui; +mod nest_rendering_ui; +mod nested_ui; +mod panels_ui; +mod par_ui; +mod perf_ui; +mod playback_clock_ui; +mod project_panel_ui; +mod properties_scroll_ui; +mod remix_ui; +mod scripted; +mod sequence_tabs_ui; +mod sequence_ui; +mod settings_ui; +mod shared_docking_ui; +mod source_drag_ui; +mod source_playback_ui; +mod timecode_ui; +mod timeline_drop_ui; +mod timeline_escape_ui; +mod timeline_gap_ui; +mod timeline_marquee_ui; +mod timeline_middle_pan_ui; +mod timeline_option_drag_ui; +mod timeline_scroll_ui; +mod timeline_tabs_ui; +mod timeline_track_height_ui; +mod timeline_transition_move_ui; +mod timeline_trim_ui; +mod track_color_ui; +mod track_select_drag_ui; +mod transcript_ui; +mod transform_handles_ui; +mod transition_drop_ui; +mod transitions_ui; +mod tts_ui; +mod ui_control_input; +mod ui_dock_resize; +mod ui_keyboard_focus; +mod unlink_ui; +mod view_ui; +mod voiceover_ui; +mod workspaces_ui; diff --git a/crates/ui-egui/tests/marked_playback_position.rs b/crates/ui-egui/tests/it/marked_playback_position.rs similarity index 100% rename from crates/ui-egui/tests/marked_playback_position.rs rename to crates/ui-egui/tests/it/marked_playback_position.rs diff --git a/crates/ui-egui/tests/mask_roundness_ui.rs b/crates/ui-egui/tests/it/mask_roundness_ui.rs similarity index 100% rename from crates/ui-egui/tests/mask_roundness_ui.rs rename to crates/ui-egui/tests/it/mask_roundness_ui.rs diff --git a/crates/ui-egui/tests/masks_ui.rs b/crates/ui-egui/tests/it/masks_ui.rs similarity index 100% rename from crates/ui-egui/tests/masks_ui.rs rename to crates/ui-egui/tests/it/masks_ui.rs diff --git a/crates/ui-egui/tests/media_ui.rs b/crates/ui-egui/tests/it/media_ui.rs similarity index 100% rename from crates/ui-egui/tests/media_ui.rs rename to crates/ui-egui/tests/it/media_ui.rs diff --git a/crates/ui-egui/tests/menus_ui.rs b/crates/ui-egui/tests/it/menus_ui.rs similarity index 100% rename from crates/ui-egui/tests/menus_ui.rs rename to crates/ui-egui/tests/it/menus_ui.rs diff --git a/crates/ui-egui/tests/meters_ui.rs b/crates/ui-egui/tests/it/meters_ui.rs similarity index 100% rename from crates/ui-egui/tests/meters_ui.rs rename to crates/ui-egui/tests/it/meters_ui.rs diff --git a/crates/ui-egui/tests/mixer_ui.rs b/crates/ui-egui/tests/it/mixer_ui.rs similarity index 100% rename from crates/ui-egui/tests/mixer_ui.rs rename to crates/ui-egui/tests/it/mixer_ui.rs diff --git a/crates/ui-egui/tests/monitor_geometry.rs b/crates/ui-egui/tests/it/monitor_geometry.rs similarity index 100% rename from crates/ui-egui/tests/monitor_geometry.rs rename to crates/ui-egui/tests/it/monitor_geometry.rs diff --git a/crates/ui-egui/tests/multicam_ui.rs b/crates/ui-egui/tests/it/multicam_ui.rs similarity index 100% rename from crates/ui-egui/tests/multicam_ui.rs rename to crates/ui-egui/tests/it/multicam_ui.rs diff --git a/crates/ui-egui/tests/native_dialogs_ui.rs b/crates/ui-egui/tests/it/native_dialogs_ui.rs similarity index 100% rename from crates/ui-egui/tests/native_dialogs_ui.rs rename to crates/ui-egui/tests/it/native_dialogs_ui.rs diff --git a/crates/ui-egui/tests/nest_editing_ui.rs b/crates/ui-egui/tests/it/nest_editing_ui.rs similarity index 100% rename from crates/ui-egui/tests/nest_editing_ui.rs rename to crates/ui-egui/tests/it/nest_editing_ui.rs diff --git a/crates/ui-egui/tests/nest_rendering_ui.rs b/crates/ui-egui/tests/it/nest_rendering_ui.rs similarity index 100% rename from crates/ui-egui/tests/nest_rendering_ui.rs rename to crates/ui-egui/tests/it/nest_rendering_ui.rs diff --git a/crates/ui-egui/tests/nested_ui.rs b/crates/ui-egui/tests/it/nested_ui.rs similarity index 100% rename from crates/ui-egui/tests/nested_ui.rs rename to crates/ui-egui/tests/it/nested_ui.rs diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/it/panels_ui.rs similarity index 91% rename from crates/ui-egui/tests/panels_ui.rs rename to crates/ui-egui/tests/it/panels_ui.rs index 483bee1c9..150f4d0bf 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/it/panels_ui.rs @@ -781,3 +781,74 @@ fn effects_are_indented_under_their_folders() { assert!(b >= a + 12.0, "`{inner}` ({b}) is not indented under `{outer}` ({a})"); } } + +/// The font of the first run of every text shape drawn this frame, with the text, position and colour. +fn painted_text(d: &Driver) -> Vec<(String, egui::Pos2, egui::FontId, egui::Color32)> { + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2, egui::FontId, egui::Color32)>) { + match s { + egui::Shape::Text(t) => { + if let Some(sec) = t.galley.job.sections.first() { + out.push((t.galley.text().to_string(), t.pos, sec.format.font_id.clone(), sec.format.color)); + } + } + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + d.harness.output().shapes.iter().for_each(|c| walk(&c.shape, &mut out)); + out +} + +// Regression test for #130: the big blue timecodes over the timeline and under the monitors were +// drawn in 15 pt semibold; Premiere's are regular weight, 16 pt, in the timecode colour. +#[test] +fn big_timecodes_are_regular_weight_16pt_in_the_timecode_colour() { + let mut d = Driver::demo(); + d.ok("ui.panel.show", json!({"panel": "Program"})); + d.ok("ui.panel.show", json!({"panel": "ReferenceMonitor"})); + d.frames(3); + let timecode = d.app().tokens.timecode; + for id in ["timeline.timecode", "program.timecode", "reference.timecode"] { + let rect = { + let v = d.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r = &e["rect"]; + egui::Rect::from_min_size( + egui::pos2(r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32), + egui::vec2(r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32), + ) + }; + let shown = d.label(id); + let (_, _, font, color) = painted_text(&d) + .into_iter() + .find(|(text, pos, ..)| *text == shown && rect.expand(2.0).contains(*pos)) + .unwrap_or_else(|| panic!("{id}: `{shown}` is not drawn in {rect:?}")); + assert_eq!(font, egui::FontId::new(16.0, egui::FontFamily::Proportional), "{id}: font"); + assert_eq!(color, timecode, "{id}: colour"); + } +} + +// Regression test for #130: the menu bar's menus were drawn in egui's default body size; Premiere's +// are the system's, larger (13 pt) and brighter than panel text. +#[test] +fn menu_bar_menus_use_the_13pt_menu_text() { + let mut d = Driver::demo(); + d.click("menu.File"); + d.frames(3); + for id in ["menu.File", "menu.file.open", "menu.file.save", "menu.file.saveAs"] { + let v = d.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no menu item {id}")).clone(); + let label = e["label"].as_str().unwrap().to_string(); + let r = &e["rect"]; + let rect = egui::Rect::from_min_size( + egui::pos2(r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32), + egui::vec2(r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32), + ); + let (_, _, font, _) = painted_text(&d) + .into_iter() + .find(|(text, pos, ..)| *text == label && rect.contains(*pos)) + .unwrap_or_else(|| panic!("`{label}` is not drawn in {rect:?}")); + assert_eq!(font.size, 13.0, "`{label}` in the File menu"); + } +} diff --git a/crates/ui-egui/tests/par_ui.rs b/crates/ui-egui/tests/it/par_ui.rs similarity index 100% rename from crates/ui-egui/tests/par_ui.rs rename to crates/ui-egui/tests/it/par_ui.rs diff --git a/crates/ui-egui/tests/perf_ui.rs b/crates/ui-egui/tests/it/perf_ui.rs similarity index 100% rename from crates/ui-egui/tests/perf_ui.rs rename to crates/ui-egui/tests/it/perf_ui.rs diff --git a/crates/ui-egui/tests/playback_clock_ui.rs b/crates/ui-egui/tests/it/playback_clock_ui.rs similarity index 100% rename from crates/ui-egui/tests/playback_clock_ui.rs rename to crates/ui-egui/tests/it/playback_clock_ui.rs diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/it/project_panel_ui.rs similarity index 89% rename from crates/ui-egui/tests/project_panel_ui.rs rename to crates/ui-egui/tests/it/project_panel_ui.rs index 87874210f..10ca52076 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/it/project_panel_ui.rs @@ -235,6 +235,59 @@ fn list_view_columns_sort_resize_and_rename() { assert_ne!(d.app().session.project.item(ItemId(items[0])).unwrap().name, "Hero shot"); } +// Regression test for #1295: in a narrow List-view column the sort arrow was painted over the end of +// the (clipped) column name; the name must be clipped to the left of the arrow. +#[test] +fn sorted_column_header_name_is_clipped_clear_of_the_sort_arrow() { + fn collect<'a>(shapes: &'a [egui::Shape], clip: egui::Rect, out: &mut Vec<(&'a egui::Shape, egui::Rect)>) { + for s in shapes { + match s { + egui::Shape::Vec(v) => collect(v, clip, out), + other => out.push((other, clip)), + } + } + } + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + d.frames(2); + // the default sort is by Name; make that column narrow enough that its name is clipped + for c in &mut d.app().session.prefs.project_panel.view.columns { + if c.name == "Name" { + c.width = 40.0; + } + } + d.frames(3); + assert_eq!(d.app().session.prefs.project_panel.view.sort.column, "Name"); + let [hx, hy, hw, hh] = d.rect("project.list.header.Name"); + let header = egui::Rect::from_min_size(egui::pos2(hx, hy), egui::vec2(hw, hh)); + let mut all = Vec::new(); + let clipped: Vec<_> = d.harness.output().shapes.iter().map(|c| (c.shape.clone(), c.clip_rect)).collect(); + for (shape, clip) in &clipped { + collect(std::slice::from_ref(shape), *clip, &mut all); + } + // the visible part of the name: the clip rect of the header's "Name" text + let name_clip = all + .iter() + .find_map(|(s, clip)| match s { + egui::Shape::Text(t) if t.galley.text() == "Name" && header.contains(t.pos) => Some(*clip), + _ => None, + }) + .expect("the Name header text is drawn"); + // the arrow: the 1.3 px strokes drawn inside the header + let arrow_x: Vec = all + .iter() + .filter_map(|(s, _)| match s { + egui::Shape::LineSegment { points, stroke } if (stroke.width - 1.3).abs() < 1e-3 && header.contains(points[0]) && header.contains(points[1]) => { + Some(points[0].x.min(points[1].x)) + } + _ => None, + }) + .collect(); + assert!(!arrow_x.is_empty(), "the sort arrow is drawn in the sorted column's header"); + let arrow_left = arrow_x.into_iter().fold(f32::MAX, f32::min); + assert!(name_clip.max.x <= arrow_left, "the name is visible up to x={} but the sort arrow starts at x={arrow_left}", name_clip.max.x); +} + #[test] fn icon_view_hover_scrub_sets_in_and_out() { let mut d = project_driver(); @@ -1128,3 +1181,65 @@ fn escape_takes_back_a_media_browser_drag() { assert_eq!(d.app().session.project.to_json(), before, "nothing was placed on the Timeline"); let _ = std::fs::remove_dir_all(&dir); } + +/// A clip dragged onto the New Item button makes a sequence from it, as File ▸ New ▸ Sequence +/// From Clip does (#2855): named after the clip, with its frame size and rate, the clip on the +/// timeline and the sequence open. One of several selected items brings the whole selection. +#[test] +fn dragging_clips_onto_new_item_makes_a_matching_sequence() { + let mut d = Driver::demo(); + let (bin, items) = d.footage(); + assert!(items.len() >= 3); + d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}})); + let card = |i: u64| format!("project.item.{i}"); + d.wait_for(&card(items[0])); + let sequences = |d: &mut Driver| d.app().session.project.items.values().filter(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))).count(); + let clips = |d: &mut Driver| d.app().session.active_sequence().map(|q| q.video_tracks.iter().map(|t| t.items.len()).sum::()).unwrap_or(0); + let drop = json!({"id": "project.button.new-item"}); + // footage that differs from a new sequence's defaults, so matching it shows + if let Some(filmcraft_project::ItemKind::Media(m)) = std::sync::Arc::make_mut(&mut d.app().session.project).item_mut(ItemId(items[0])).map(|i| &mut i.kind) + && let Some(v) = m.info.video.as_mut() + { + (v.width, v.height, v.frame_rate) = (1280, 720, filmcraft_time::FrameRate::FPS_25); + } + let def = filmcraft_project::SequenceSettings::default(); + assert_ne!((def.width, def.height, def.frame_rate), (1280, 720, filmcraft_time::FrameRate::FPS_25)); + let n0 = sequences(&mut d); + + // a drop on another footer button, or a Source Monitor drag, makes nothing + d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": {"id": "project.button.file.newBin"}, "steps": 16})); + d.frames(4); + assert_eq!(sequences(&mut d), n0, "New Bin is not a drop target"); + d.exec("source.open", json!({"item": items[0]})); + d.wait_for("source.drag.both"); + d.ok("ui.drag", json!({"from": {"id": "source.drag.both"}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(sequences(&mut d), n0, "a Source Monitor drag is not taken"); + + // an item that is not selected: a sequence of that clip alone + d.exec("project.select", json!({"items": [items[2]]})); + let steps = d.app().session.history.undo.len(); + d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(sequences(&mut d), n0 + 1, "the drop makes one sequence"); + assert!(!d.has("project.newItem.file.newSequence"), "the drop does not open the New Item menu"); + let app = d.app(); + let clip = app.session.project.item(ItemId(items[0])).unwrap().clone(); + let filmcraft_project::ItemKind::Media(m) = &clip.kind else { panic!("footage is media") }; + let v = m.info.video.clone().expect("footage has video"); + let q = app.session.active_sequence().expect("the new sequence is active"); + let seq = app.session.state.active_sequence.unwrap(); + assert_eq!(app.session.project.item(seq).unwrap().name, clip.name, "named after the clip"); + assert_eq!((q.settings.width, q.settings.height, q.settings.frame_rate), (v.width, v.height, v.frame_rate), "the clip's frame size and rate"); + assert_eq!(q.video_tracks[0].items.first().map(|c| c.item), Some(ItemId(items[0])), "the clip is on the timeline"); + assert_eq!(app.session.history.undo.len(), steps + 1, "one undo step"); + assert_eq!(clips(&mut d), 1, "an unselected item goes alone"); + + // two of the selection: both, back to back, in one sequence + d.exec("project.select", json!({"items": [items[0], items[1]]})); + d.frames(2); + d.ok("ui.drag", json!({"from": {"id": card(items[1])}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(sequences(&mut d), n0 + 2); + assert_eq!(clips(&mut d), 2, "the whole selection is placed"); +} diff --git a/crates/ui-egui/tests/properties_scroll_ui.rs b/crates/ui-egui/tests/it/properties_scroll_ui.rs similarity index 100% rename from crates/ui-egui/tests/properties_scroll_ui.rs rename to crates/ui-egui/tests/it/properties_scroll_ui.rs diff --git a/crates/ui-egui/tests/remix_ui.rs b/crates/ui-egui/tests/it/remix_ui.rs similarity index 100% rename from crates/ui-egui/tests/remix_ui.rs rename to crates/ui-egui/tests/it/remix_ui.rs diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/it/scripted.rs similarity index 98% rename from crates/ui-egui/tests/scripted.rs rename to crates/ui-egui/tests/it/scripted.rs index 0bb1dd741..f9e2cc9ec 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/it/scripted.rs @@ -191,10 +191,11 @@ fn spanish_search_finds_effects_and_presets_with_stable_automation_ids() { let mut d = Driver::demo(); d.ok("ui.menu.invoke", json!({"id": "app.language.spanish"})); d.ok("ui.panel.show", json!({"panel": "Effects"})); - d.ok("ui.set", json!({"effectsSearch": "DESENFOQUE"})); + let es = filmcraft_ui_egui::i18n::Language::Es; + d.ok("ui.set", json!({"effectsSearch": es.tr("Gaussian Blur").to_uppercase()})); d.frames(3); assert!(d.element_ids("effects.item.").contains(&"effects.item.gaussian_blur".to_string())); - d.ok("ui.set", json!({"effectsSearch": "ENTRADA CON DESENFOQUE"})); + d.ok("ui.set", json!({"effectsSearch": es.tr("Blur In").to_uppercase()})); d.frames(3); assert!(d.element_ids("effects.preset.").contains(&"effects.preset.Blur In".to_string())); let clip = track_clips(&d.sequence(), 0)[0]["clip"].as_u64().unwrap(); @@ -401,7 +402,7 @@ impl Driver { } /// Renders the Trim Monitor and the Keyboard Shortcuts dialog to PNGs for visual review (needs a -/// GPU): `cargo test -p filmcraft-ui-egui --test scripted -- --ignored screenshots`. +/// GPU): `cargo test -p filmcraft-ui-egui --test it scripted::screenshots -- --ignored`. #[test] #[ignore] fn screenshots() { diff --git a/crates/ui-egui/tests/sequence_tabs_ui.rs b/crates/ui-egui/tests/it/sequence_tabs_ui.rs similarity index 100% rename from crates/ui-egui/tests/sequence_tabs_ui.rs rename to crates/ui-egui/tests/it/sequence_tabs_ui.rs diff --git a/crates/ui-egui/tests/sequence_ui.rs b/crates/ui-egui/tests/it/sequence_ui.rs similarity index 100% rename from crates/ui-egui/tests/sequence_ui.rs rename to crates/ui-egui/tests/it/sequence_ui.rs diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/it/settings_ui.rs similarity index 100% rename from crates/ui-egui/tests/settings_ui.rs rename to crates/ui-egui/tests/it/settings_ui.rs diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs similarity index 100% rename from crates/ui-egui/tests/shared_docking_ui.rs rename to crates/ui-egui/tests/it/shared_docking_ui.rs diff --git a/crates/ui-egui/tests/source_drag_ui.rs b/crates/ui-egui/tests/it/source_drag_ui.rs similarity index 100% rename from crates/ui-egui/tests/source_drag_ui.rs rename to crates/ui-egui/tests/it/source_drag_ui.rs diff --git a/crates/ui-egui/tests/source_playback_ui.rs b/crates/ui-egui/tests/it/source_playback_ui.rs similarity index 100% rename from crates/ui-egui/tests/source_playback_ui.rs rename to crates/ui-egui/tests/it/source_playback_ui.rs diff --git a/crates/ui-egui/tests/timecode_ui.rs b/crates/ui-egui/tests/it/timecode_ui.rs similarity index 100% rename from crates/ui-egui/tests/timecode_ui.rs rename to crates/ui-egui/tests/it/timecode_ui.rs diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/it/timeline_drop_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_drop_ui.rs rename to crates/ui-egui/tests/it/timeline_drop_ui.rs diff --git a/crates/ui-egui/tests/timeline_escape_ui.rs b/crates/ui-egui/tests/it/timeline_escape_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_escape_ui.rs rename to crates/ui-egui/tests/it/timeline_escape_ui.rs diff --git a/crates/ui-egui/tests/timeline_gap_ui.rs b/crates/ui-egui/tests/it/timeline_gap_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_gap_ui.rs rename to crates/ui-egui/tests/it/timeline_gap_ui.rs diff --git a/crates/ui-egui/tests/timeline_marquee_ui.rs b/crates/ui-egui/tests/it/timeline_marquee_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_marquee_ui.rs rename to crates/ui-egui/tests/it/timeline_marquee_ui.rs diff --git a/crates/ui-egui/tests/timeline_middle_pan_ui.rs b/crates/ui-egui/tests/it/timeline_middle_pan_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_middle_pan_ui.rs rename to crates/ui-egui/tests/it/timeline_middle_pan_ui.rs diff --git a/crates/ui-egui/tests/timeline_option_drag_ui.rs b/crates/ui-egui/tests/it/timeline_option_drag_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_option_drag_ui.rs rename to crates/ui-egui/tests/it/timeline_option_drag_ui.rs diff --git a/crates/ui-egui/tests/timeline_scroll_ui.rs b/crates/ui-egui/tests/it/timeline_scroll_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_scroll_ui.rs rename to crates/ui-egui/tests/it/timeline_scroll_ui.rs diff --git a/crates/ui-egui/tests/timeline_tabs_ui.rs b/crates/ui-egui/tests/it/timeline_tabs_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_tabs_ui.rs rename to crates/ui-egui/tests/it/timeline_tabs_ui.rs diff --git a/crates/ui-egui/tests/timeline_track_height_ui.rs b/crates/ui-egui/tests/it/timeline_track_height_ui.rs similarity index 54% rename from crates/ui-egui/tests/timeline_track_height_ui.rs rename to crates/ui-egui/tests/it/timeline_track_height_ui.rs index eb272be4b..53e54c6c9 100644 --- a/crates/ui-egui/tests/timeline_track_height_ui.rs +++ b/crates/ui-egui/tests/it/timeline_track_height_ui.rs @@ -112,3 +112,83 @@ fn the_bottom_edge_of_the_last_audio_track_resizes_the_audio_tracks_from_either_ d.drag(&id, 0.1, -15.0); assert!(d.heights().1 < a1 - 5.0); } + +/// Every shape drawn this frame, flattened. +fn flat_shapes(d: &Driver) -> Vec { + fn walk(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + other => out.push(other.clone()), + } + } + let mut out = Vec::new(); + d.harness.output().shapes.iter().for_each(|c| walk(&c.shape, &mut out)); + out +} + +fn rect_of(d: &mut Driver, id: &str) -> egui::Rect { + let v = d.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|n| n.as_f64().unwrap() as f32).collect(); + egui::Rect::from_min_size(egui::pos2(r[0], r[1]), egui::vec2(r[2], r[3])) +} + +// Regression test for #703: the lines between the timeline's tracks were drawn in the timeline's own +// background colour, so they were invisible, and nothing marked the top of the highest video track. +#[test] +fn timeline_draws_visible_lines_between_tracks_and_above_the_top_video_track() { + let mut d = Driver::demo(); + let (top, _) = d.labels(); + let separator = d.app().tokens.separator; + let ruler = rect_of(&mut d, "timeline.ruler"); + let top_edge = rect_of(&mut d, &format!("timeline.track.{top}.resizeTop")); + let seq = d.app().session.active_sequence().unwrap(); + let tracks = seq.video_tracks.len() + seq.audio_tracks.len(); + // horizontal separator lines across the track area (the headers' own lines start left of the ruler) + let mut ys: Vec = flat_shapes(&d) + .into_iter() + .filter_map(|s| match s { + egui::Shape::LineSegment { points, stroke } + if stroke.color == separator + && points[0].y == points[1].y + && points[0].x >= ruler.min.x - 1.0 + && points[1].x - points[0].x > 200.0 + && points[0].y > ruler.max.y => + { + Some(points[0].y) + } + _ => None, + }) + .collect(); + ys.sort_by(f32::total_cmp); + ys.dedup_by(|a, b| (*a - *b).abs() < 0.1); + assert!(ys.len() > tracks, "a line under each of the {tracks} tracks and one above the top video track, found {ys:?}"); + let first = ys[0]; + assert!((first - top_edge.center().y).abs() < 1.5, "the first line ({first}) is at the top edge of the highest video track ({})", top_edge.center().y); +} + +// Regression test for #703: the playhead's head spanned the whole ruler and covered the markers and +// the timecode labels; it must be a short head on the tick row at the bottom of the ruler. +#[test] +fn playhead_head_is_short_and_sits_on_the_ruler_tick_row() { + let mut d = Driver::demo(); + let playhead = d.app().tokens.playhead; + let ruler = rect_of(&mut d, "timeline.ruler"); + let heads: Vec = flat_shapes(&d) + .into_iter() + .filter_map(|s| match s { + egui::Shape::Path(p) if p.fill == playhead && p.points.len() == 5 => Some(egui::Rect::from_points(&p.points)), + _ => None, + }) + .filter(|r| r.max.y <= ruler.max.y + 1.0 && r.min.y >= ruler.min.y) + .collect(); + assert_eq!(heads.len(), 1, "one playhead head over the ruler: {heads:?}"); + let head = heads[0]; + assert!(head.height() <= 14.0, "the head is {} tall, ruler is {}", head.height(), ruler.height()); + assert!( + head.min.y >= ruler.max.y - 14.0, + "the head starts at y={}, leaving the ruler's marker and label rows (above y={}) clear", + head.min.y, + ruler.max.y - 14.0 + ); +} diff --git a/crates/ui-egui/tests/timeline_transition_move_ui.rs b/crates/ui-egui/tests/it/timeline_transition_move_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_transition_move_ui.rs rename to crates/ui-egui/tests/it/timeline_transition_move_ui.rs diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/it/timeline_trim_ui.rs similarity index 99% rename from crates/ui-egui/tests/timeline_trim_ui.rs rename to crates/ui-egui/tests/it/timeline_trim_ui.rs index 94aabf400..d6243d548 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/it/timeline_trim_ui.rs @@ -256,7 +256,7 @@ fn cursor_hit_test_and_press_agree_at_a_cut() { } /// Renders the hover bracket at the City_Night_Drive / Desert_Dunes cut to PNGs for visual -/// review (needs a GPU): `cargo test -p filmcraft-ui-egui --test timeline_trim_ui -- --ignored +/// review (needs a GPU): `cargo test -p filmcraft-ui-egui --test it timeline_trim_ui:: -- --ignored /// hover_bracket_screenshots`. Yellow on City_Night_Drive's Out edge just left of the cut and on /// Desert_Dunes' In edge just right of it, red for the Ripple tool, both sides for the Rolling tool. #[test] @@ -301,7 +301,7 @@ fn dragging_a_caption_edge_inward_trims_it() { /// #374: renders the Ambient_Score music clip mid-way through a trim of its In edge, with the /// mouse button still down, for visual review (needs a GPU): `cargo test -p filmcraft-ui-egui -/// --test timeline_trim_ui -- --ignored trim_waveform_screenshots`. The waveform stays where it was +/// --test it timeline_trim_ui::trim_waveform_screenshots -- --ignored`. The waveform stays where it was /// and is cut off at the moving edge; it used to squeeze the whole clip's waveform into the shorter /// clip, so the sound under the pointer moved while dragging. #[test] diff --git a/crates/ui-egui/tests/track_color_ui.rs b/crates/ui-egui/tests/it/track_color_ui.rs similarity index 100% rename from crates/ui-egui/tests/track_color_ui.rs rename to crates/ui-egui/tests/it/track_color_ui.rs diff --git a/crates/ui-egui/tests/track_select_drag_ui.rs b/crates/ui-egui/tests/it/track_select_drag_ui.rs similarity index 100% rename from crates/ui-egui/tests/track_select_drag_ui.rs rename to crates/ui-egui/tests/it/track_select_drag_ui.rs diff --git a/crates/ui-egui/tests/transcript_ui.rs b/crates/ui-egui/tests/it/transcript_ui.rs similarity index 100% rename from crates/ui-egui/tests/transcript_ui.rs rename to crates/ui-egui/tests/it/transcript_ui.rs diff --git a/crates/ui-egui/tests/transform_handles_ui.rs b/crates/ui-egui/tests/it/transform_handles_ui.rs similarity index 100% rename from crates/ui-egui/tests/transform_handles_ui.rs rename to crates/ui-egui/tests/it/transform_handles_ui.rs diff --git a/crates/ui-egui/tests/transition_drop_ui.rs b/crates/ui-egui/tests/it/transition_drop_ui.rs similarity index 100% rename from crates/ui-egui/tests/transition_drop_ui.rs rename to crates/ui-egui/tests/it/transition_drop_ui.rs diff --git a/crates/ui-egui/tests/transitions_ui.rs b/crates/ui-egui/tests/it/transitions_ui.rs similarity index 100% rename from crates/ui-egui/tests/transitions_ui.rs rename to crates/ui-egui/tests/it/transitions_ui.rs diff --git a/crates/ui-egui/tests/tts_ui.rs b/crates/ui-egui/tests/it/tts_ui.rs similarity index 100% rename from crates/ui-egui/tests/tts_ui.rs rename to crates/ui-egui/tests/it/tts_ui.rs diff --git a/crates/ui-egui/tests/ui_control_input.rs b/crates/ui-egui/tests/it/ui_control_input.rs similarity index 92% rename from crates/ui-egui/tests/ui_control_input.rs rename to crates/ui-egui/tests/it/ui_control_input.rs index 773b451dd..83b3c054e 100644 --- a/crates/ui-egui/tests/ui_control_input.rs +++ b/crates/ui-egui/tests/it/ui_control_input.rs @@ -275,3 +275,22 @@ fn modified_control_drag_keeps_shift_until_release_and_clears_it_afterward() { d.frames(3, false); assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE); } + +/// CodeRabbit on #2006: in control-only mode the native menu's commands are ignored, but its Quit +/// item (now `app.quit` through the command inbox, #1943) must still close the window, as the +/// predefined Quit item did. +#[test] +fn native_quit_still_quits_in_control_only_mode() { + let mut d = Driver::new(true); + let (tx, rx) = channel(); + d.h.state_mut().command_inbox = Some(rx); + // another menu command is ignored + let project = d.h.state().session.project.clone(); + tx.send("file.newBin".to_string()).unwrap(); + d.frames(2, true); + assert!(Arc::ptr_eq(&project, &d.h.state().session.project), "menu commands do not run in control-only mode"); + tx.send("app.quit".to_string()).unwrap(); + d.frames(1, true); + let closing = d.h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Close)); + assert!(closing, "Quit asks the window to close"); +} diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/it/ui_dock_resize.rs similarity index 100% rename from crates/ui-egui/tests/ui_dock_resize.rs rename to crates/ui-egui/tests/it/ui_dock_resize.rs diff --git a/crates/ui-egui/tests/ui_keyboard_focus.rs b/crates/ui-egui/tests/it/ui_keyboard_focus.rs similarity index 100% rename from crates/ui-egui/tests/ui_keyboard_focus.rs rename to crates/ui-egui/tests/it/ui_keyboard_focus.rs diff --git a/crates/ui-egui/tests/unlink_ui.rs b/crates/ui-egui/tests/it/unlink_ui.rs similarity index 100% rename from crates/ui-egui/tests/unlink_ui.rs rename to crates/ui-egui/tests/it/unlink_ui.rs diff --git a/crates/ui-egui/tests/view_ui.rs b/crates/ui-egui/tests/it/view_ui.rs similarity index 100% rename from crates/ui-egui/tests/view_ui.rs rename to crates/ui-egui/tests/it/view_ui.rs diff --git a/crates/ui-egui/tests/voiceover_ui.rs b/crates/ui-egui/tests/it/voiceover_ui.rs similarity index 100% rename from crates/ui-egui/tests/voiceover_ui.rs rename to crates/ui-egui/tests/it/voiceover_ui.rs diff --git a/crates/ui-egui/tests/workspaces_ui.rs b/crates/ui-egui/tests/it/workspaces_ui.rs similarity index 100% rename from crates/ui-egui/tests/workspaces_ui.rs rename to crates/ui-egui/tests/it/workspaces_ui.rs diff --git a/crates/vp8/Cargo.toml b/crates/vp8/Cargo.toml index 52f274907..8089708d3 100644 --- a/crates/vp8/Cargo.toml +++ b/crates/vp8/Cargo.toml @@ -11,3 +11,6 @@ workspace = true [dependencies] thiserror = { workspace = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/vp8/README.md b/crates/vp8/README.md index affb7ff34..5b855a21f 100644 --- a/crates/vp8/README.md +++ b/crates/vp8/README.md @@ -28,8 +28,8 @@ test oracles. Bit-exact with libvpx on the 62 libvpx VP8 test vectors checked (`vp80-00-comprehensive-001` to `018` and the `vp80-01` to `vp80-06` intra, inter, segmentation, partition, sharpness and small -size vectors; `tests/vectors.rs` checks them when they are present) and on the generated fixtures -of `tests/conformance.rs`. +size vectors; `tests/it/vectors.rs` checks them when they are present) and on the generated fixtures +of `tests/it/conformance.rs`. ## API diff --git a/crates/vp8/src/decoder.rs b/crates/vp8/src/decoder.rs index 0c030c740..50d8c517d 100644 --- a/crates/vp8/src/decoder.rs +++ b/crates/vp8/src/decoder.rs @@ -15,6 +15,9 @@ use crate::transform; /// Largest frame dimension in macroblocks (14-bit sizes). const MAX_MBS: usize = 16384 / 16 + 1; +/// Maximum padded luma samples retained by one VP8 reference picture (about 8K UHD). +/// 2^26 luma samples: 8192x8192, above every VP8 frame a camera or encoder writes (8192x4320 is 35M). +const MAX_LUMA_SAMPLES: usize = 1 << 26; /// A reconstructed frame: planes sized to whole macroblocks. #[derive(Clone)] @@ -162,14 +165,18 @@ impl Decoder { /// (`show_frame`), `None` for a hidden frame (an altref). pub fn decode(&mut self, data: &[u8], pts: i64) -> Result> { let tag = FrameTag::parse(data)?; + ensure!(tag.first_part_size > 0, "empty first partition"); if tag.key_frame { ensure!(tag.width > 0 && tag.height > 0, "key frame of size {}x{}", tag.width, tag.height); let (w, h) = (usize::from(tag.width), usize::from(tag.height)); + let (mb_w, mb_h) = (w.div_ceil(16), h.div_ceil(16)); + let samples = mb_w.checked_mul(16).and_then(|w| w.checked_mul(mb_h)).and_then(|h| h.checked_mul(16)); + ensure!(samples.is_some_and(|n| n <= MAX_LUMA_SAMPLES), "frame size {}x{} exceeds the decoder memory limit", tag.width, tag.height); if w != self.width || h != self.height { self.width = w; self.height = h; - self.mb_w = w.div_ceil(16); - self.mb_h = h.div_ceil(16); + self.mb_w = mb_w; + self.mb_h = mb_h; self.seg_map = vec![0; self.mb_w * self.mb_h]; } // key frame defaults (9.3, 9.4, 9.7, 13.4, 16.1, 17.2) diff --git a/crates/vp8/tests/fuzz_hostile.rs b/crates/vp8/tests/fuzz_hostile.rs new file mode 100644 index 000000000..e72e61bc7 --- /dev/null +++ b/crates/vp8/tests/fuzz_hostile.rs @@ -0,0 +1,158 @@ +//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small +//! ffmpeg-generated VP8 streams (frame by frame) must never panic or run away. Skips without ffmpeg. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use filmcraft_vp8::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, encoder args, pix_fmt) +const SEEDS: &[(&str, &str, &str)] = &[ + ("vp8_fz_default", "-c:v libvpx -g 4 -b:v 300k", "yuv420p"), + ("vp8_fz_partitions", "-c:v libvpx -g 6 -b:v 300k -slices 8 -auto-alt-ref 0 -lag-in-frames 0 -error-resilient 1", "yuv420p"), +]; + +fn ivf_frames(data: &[u8]) -> Vec> { + 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("vp8").join(format!("{name}.ivf")); + let p = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let a: Vec<&str> = args.split_whitespace().collect(); + matches!( + Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix]) + .args(a) + .args(["-f", "ivf"]) + .arg(tmp) + .stdin(Stdio::null()) + .status(), + Ok(s) if s.success() + ) + })?; + Some(ivf_frames(&std::fs::read(p).ok()?)) +} + +static PANICS: AtomicUsize = AtomicUsize::new(0); + +/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(frames: &[Vec], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + for _ in 0..1 { + let mut dec = Decoder::new(); + for (i, f) in frames.iter().enumerate() { + let _ = dec.decode(f, i as i64); + } + } + })); + assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt). +fn iters() -> usize { + std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300) +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP8 fuzz") else { return }; + for &(name, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx") { + filmcraft_testkit::skip("VP8 fuzz", &format!("this ffmpeg has no libvpx encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libvpx but could not encode the {name} seed"); + }; + if seed.is_empty() { + filmcraft_testkit::skip("VP8 fuzz", &format!("the {name} seed has no frames")); + continue; + } + // truncate each of the first frames at every length + for fi in 0..seed.len().min(3) { + let len = seed[fi].len(); + for n in (0..len).step_by((len / 200).max(1)) { + let mut d = seed.clone(); + d[fi].truncate(n); + run_all(&d, &format!("{name} frame {fi} truncate {n}")); + } + } + // header region of every frame: every byte to a few values, every bit + for fi in 0..seed.len() { + for p in 0..seed[fi].len().min(96) { + for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + let mut d = seed.clone(); + d[fi][p] = v; + run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}")); + } + for b in 0..8 { + let mut d = seed.clone(); + d[fi][p] ^= 1 << b; + run_all(&d, &format!("{name} frame {fi} flip {p}.{b}")); + } + } + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); + for i in 0..iters() { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(6) { + let fi = rng.below(d.len()); + if d[fi].is_empty() { + continue; + } + let p = rng.below(d[fi].len()); + if rng.below(2) == 0 { + d[fi][p] ^= 1 << rng.below(8); + } else { + d[fi][p] = rng.next() as u8; + } + } + run_all(&d, &format!("{name} random {i}")); + } + } +} diff --git a/crates/vp8/tests/common/mod.rs b/crates/vp8/tests/it/common/mod.rs similarity index 94% rename from crates/vp8/tests/common/mod.rs rename to crates/vp8/tests/it/common/mod.rs index cc42e8e88..15416b864 100644 --- a/crates/vp8/tests/common/mod.rs +++ b/crates/vp8/tests/it/common/mod.rs @@ -108,15 +108,17 @@ pub fn run(cmd: &mut Command) -> bool { /// Generate (if needed) the fixture stream and its reference decode by libvpx through ffmpeg. /// `None` (the test skips) without ffmpeg or its libvpx encoder. pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { - // one generation at a time: tests share fixtures, and realtime libvpx encodes differ from run - // to run, so a stream and its reference decode must come from the same encode - static GENERATING: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _guard = GENERATING.lock().unwrap_or_else(|e| e.into_inner()); let Some(ff) = ffmpeg() else { eprintln!("SKIP {}: ffmpeg not found", f.name); return None; }; let dir = fixtures_dir(); + // one generation at a time, across test processes too: tests share fixtures, and realtime + // libvpx encodes differ from run to run, so a stream and its reference decode must come from + // the same encode + // a lock failure is a broken environment, not a missing tool: fail rather than skip + let lock = std::fs::File::create(dir.join("vp8.generate.lock")).expect("create the fixture lock file"); + lock.lock().expect("lock the fixture lock file"); let ivf = dir.join(format!("{}.ivf", f.name)); let yuv = dir.join(format!("{}.yuv", f.name)); if !ivf.exists() { diff --git a/crates/vp8/tests/conformance.rs b/crates/vp8/tests/it/conformance.rs similarity index 99% rename from crates/vp8/tests/conformance.rs rename to crates/vp8/tests/it/conformance.rs index 27c7b92aa..3f120877f 100644 --- a/crates/vp8/tests/conformance.rs +++ b/crates/vp8/tests/it/conformance.rs @@ -1,7 +1,7 @@ //! Bit-exactness against libvpx (through ffmpeg) on generated fixtures covering every coding //! tool: skipped when ffmpeg or its libvpx encoder is absent. -mod common; +use crate::common; fn check(name: &str) { let f = common::fixture(name); diff --git a/crates/vp8/tests/it/main.rs b/crates/vp8/tests/it/main.rs new file mode 100644 index 000000000..ffc58b90c --- /dev/null +++ b/crates/vp8/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod conformance; +mod robustness; +mod vectors; diff --git a/crates/vp8/tests/robustness.rs b/crates/vp8/tests/it/robustness.rs similarity index 86% rename from crates/vp8/tests/robustness.rs rename to crates/vp8/tests/it/robustness.rs index 7dfc4f897..0c5081402 100644 --- a/crates/vp8/tests/robustness.rs +++ b/crates/vp8/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! Damaged, truncated, reordered and random input must never panic (errors are fine). -mod common; +use crate::common; use filmcraft_vp8::{Decoder, Error}; @@ -78,7 +78,15 @@ fn random_bytes_and_tiny_frames_do_not_panic() { key[3..6].copy_from_slice(&[0x9d, 0x01, 0x2a]); key[6..8].copy_from_slice(&1920u16.to_le_bytes()); key[8..10].copy_from_slice(&1080u16.to_le_bytes()); - let _ = dec.decode(&key, 0); + assert!(dec.decode(&key, 0).is_err(), "empty first partition must not reconstruct a frame"); + + // A header can declare nearly 16K square dimensions with just ten bytes. It must be rejected + // before the macroblock map and reference planes are allocated. + key[0] = 0x30; // one-byte first partition + key[10] = 0; + key[6..8].copy_from_slice(&16_383u16.to_le_bytes()); + key[8..10].copy_from_slice(&16_383u16.to_le_bytes()); + assert!(dec.decode(&key, 0).is_err(), "oversized key frame must be rejected"); } #[test] diff --git a/crates/vp8/tests/vectors.rs b/crates/vp8/tests/it/vectors.rs similarity index 99% rename from crates/vp8/tests/vectors.rs rename to crates/vp8/tests/it/vectors.rs index 789a40f56..d992c1926 100644 --- a/crates/vp8/tests/vectors.rs +++ b/crates/vp8/tests/it/vectors.rs @@ -3,7 +3,7 @@ //! downloaded by the tests; fetch them from //! `https://storage.googleapis.com/downloads.webmproject.org/test_data/libvpx/`. -mod common; +use crate::common; /// MD5 (RFC 1321), for comparing with the vectors' checksums. fn md5(data: &[u8]) -> String { diff --git a/crates/vp8/tools/extract_tables.py b/crates/vp8/tools/extract_tables.py index 5f7710e93..2daaf87e4 100644 --- a/crates/vp8/tools/extract_tables.py +++ b/crates/vp8/tools/extract_tables.py @@ -11,8 +11,18 @@ emitted flat (row-major, as printed) with its element count checked against the """ import re import sys +from pathlib import Path -text = open(sys.argv[1], encoding="utf-8").read() + +def _spec_path(arg): + """Resolve a CLI path argument and require it to be an existing regular file.""" + path = Path(arg).resolve() + if not path.is_file(): + sys.exit(f"not a readable file: {arg}") + return path + + +text = open(_spec_path(sys.argv[1]), encoding="utf-8").read() cut = text.find("\n20. Attachment One") if cut < 0: sys.exit("section 20 not found") diff --git a/crates/vp9/Cargo.toml b/crates/vp9/Cargo.toml index 5e9fd06e3..c8aef8ca3 100644 --- a/crates/vp9/Cargo.toml +++ b/crates/vp9/Cargo.toml @@ -19,3 +19,4 @@ thiserror = { workspace = true } rayon = { workspace = true, optional = true } [dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/vp9/README.md b/crates/vp9/README.md index aea3a7781..765eb4b0c 100644 --- a/crates/vp9/README.md +++ b/crates/vp9/README.md @@ -115,16 +115,16 @@ draft mode. - Draft mode (`draft_mode_changes_only_flagged_non_reference_frames`): on a two-layer temporal scalability stream, single- and frame-threaded, every unflagged frame is bit-exact and only flagged frames differ. -- Conformance (`tests/conformance.rs`): the fixture matrix below plus a stream concatenating two +- Conformance (`tests/it/conformance.rs`): the fixture matrix below plus a stream concatenating two sizes. `compare_detects_mismatch` guards the harness. `coverage_report` (ignored) prints the decoder statistics for each fixture. -- Synthetic (`tests/synthetic.rs`): headers of libvpx output are rewritten to produce what ffmpeg's +- Synthetic (`tests/it/synthetic.rs`): headers of libvpx output are rewritten to produce what ffmpeg's libvpx wrapper cannot: inter frames of a stream encoded at another size spliced after a 352x288 key frame with explicit frame sizes, i.e. **scaled reference prediction** (downscale 256x208, upscale 480x400, odd 301x199; a same-size splice is the control); a key frame rewritten as a hidden **intra-only frame** (reset_frame_context 3 / 2 / 0, various refresh masks) shown with **show_existing_frame**; show_existing_frame of every slot, also inside a superframe. -- Robustness (`tests/robustness.rs`): bit flips, byte replacement and truncation of real streams, +- Robustness (`tests/it/robustness.rs`): bit flips, byte replacement and truncation of real streams, truncated frames, random data and oversized frames never panic (debug and release) or hang, single- and multi-threaded. @@ -158,7 +158,7 @@ Backward adaptation is on (`-frame-parallel 0`) unless noted. ## Performance -`cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture` (bit-exactness is +`cargo test --release -p filmcraft-vp9 --test it perf:: -- --ignored --nocapture` (bit-exactness is verified first), or `VP9_THREADS=n VP9_LOOPS=k cargo run --release -p filmcraft-vp9 --example vp9dec -- in.ivf` (decodes the file k times and prints the statistics; `VP9_DRAFT=1` for draft mode). diff --git a/crates/vp9/src/decoder.rs b/crates/vp9/src/decoder.rs index af1f969c7..e220a7f62 100644 --- a/crates/vp9/src/decoder.rs +++ b/crates/vp9/src/decoder.rs @@ -15,6 +15,10 @@ use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex, MutexGuard, RwLock}; +/// Maximum luma samples accepted for one decoded reference frame: 8192x8192, above level 6.2's +/// 8192x4352 (35 651 584 samples). +const MAX_LUMA_SAMPLES: u64 = 1 << 26; + /// Counters describing what the decoder has seen (useful to check test coverage). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DecodeStats { @@ -257,6 +261,12 @@ impl Decoder { return Ok(()); } ensure!(h.width <= 16384 && h.height <= 16384, "frame size {}x{} too large", h.width, h.height); + ensure!( + u64::from(h.width).checked_mul(u64::from(h.height)).is_some_and(|n| n <= MAX_LUMA_SAMPLES), + "frame size {}x{} exceeds the decoder memory limit", + h.width, + h.height + ); let (mi_rows, mi_cols) = (h.mi_rows as usize, h.mi_cols as usize); // compute_image_size semantics (7.2.6). let size = (h.width, h.height); @@ -279,6 +289,7 @@ impl Decoder { let off = h.uncompressed_size; let hs = h.header_size_in_bytes as usize; ensure!(off + hs <= data.len(), "compressed header exceeds frame data"); + ensure!(off + hs < data.len(), "frame has no tile data"); let mut fc = self.st.contexts[h.frame_context_idx as usize].clone(); parse_compressed(&data[off..off + hs], &mut h, &mut fc)?; self.count_frame(&h); diff --git a/crates/vp9/tests/fuzz_hostile.rs b/crates/vp9/tests/fuzz_hostile.rs new file mode 100644 index 000000000..a04227331 --- /dev/null +++ b/crates/vp9/tests/fuzz_hostile.rs @@ -0,0 +1,221 @@ +//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small +//! ffmpeg-generated VP9 streams (frame by frame) must never panic or run away. Skips without ffmpeg. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use filmcraft_vp9::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, encoder args, pix_fmt) +const SEEDS: &[(&str, &str, &str)] = &[ + ("vp9_fz_default", "-c:v libvpx-vp9 -g 4 -b:v 300k -lag-in-frames 0", "yuv420p"), + // libvpx needs 256 px per tile column, so two columns need 512 px + ("vp9_fz_2tiles_10bit", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -tile-columns 1 -profile:v 2 -s 512x48", "yuv420p10le"), + ("vp9_fz_444", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -profile:v 1", "yuv444p"), + ("vp9_fz_altref", "-c:v libvpx-vp9 -g 6 -b:v 300k -auto-alt-ref 1 -lag-in-frames 5", "yuv420p"), +]; + +fn ivf_frames(data: &[u8]) -> Vec> { + 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("vp9").join(format!("{name}.ivf")); + let p = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let a: Vec<&str> = args.split_whitespace().collect(); + matches!( + Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix]) + .args(a) + .args(["-f", "ivf"]) + .arg(tmp) + .stdin(Stdio::null()) + .status(), + Ok(s) if s.success() + ) + })?; + Some(ivf_frames(&std::fs::read(p).ok()?)) +} + +static PANICS: AtomicUsize = AtomicUsize::new(0); + +/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(frames: &[Vec], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + for threads in [1, 3] { + let mut dec = Decoder::with_threads(threads); + for (i, f) in frames.iter().enumerate() { + let _ = dec.decode(f, i as i64); + } + let _ = dec.flush(); + } + })); + assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}"); +} + +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 200 truncation +/// points spread over each of the first three frames, every header byte, overwrite value and +/// bit flip) plus N random rounds per seed: 300 is the full depth, more hunts longer. Unset, CI +/// runs a third of the truncation points and of the full depth's random rounds and, for each +/// header byte of each frame, one value and one bit, shifted by the frame index. A rotation +/// offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC day number), so every +/// case of the full sweep runs within a few days, and a random round's mutations match the same +/// round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 200, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=300 runs everything)"); + Depth { full: false, truncations: 200, rounds: 300, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, fi: usize, p: usize, i: usize) -> bool { + self.full || i == (p + fi + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, fi: usize, p: usize, b: usize) -> bool { + self.full || b == (p + fi + self.rotation % 8) % 8 + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=300` runs all of it (see [`Depth`]). +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP9 fuzz") else { return }; + let depth = Depth::from_env(); + for &(name, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx-vp9") { + filmcraft_testkit::skip("VP9 fuzz", &format!("this ffmpeg has no libvpx-vp9 encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libvpx-vp9 but could not encode the {name} seed"); + }; + if seed.is_empty() { + filmcraft_testkit::skip("VP9 fuzz", &format!("the {name} seed has no frames")); + continue; + } + // truncate each of the first frames at every length + for fi in 0..seed.len().min(3) { + let len = seed[fi].len(); + for n in depth.truncations(len) { + 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 (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(fi, p, i) { + continue; + } + 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 { + if !depth.bit(fi, p, b) { + continue; + } + 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); + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { + 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; + } + } + if depth.round(i) { + run_all(&d, &format!("{name} random {i}")); + } + } + } +} diff --git a/crates/vp9/tests/common/mod.rs b/crates/vp9/tests/it/common/mod.rs similarity index 100% rename from crates/vp9/tests/common/mod.rs rename to crates/vp9/tests/it/common/mod.rs diff --git a/crates/vp9/tests/conformance.rs b/crates/vp9/tests/it/conformance.rs similarity index 99% rename from crates/vp9/tests/conformance.rs rename to crates/vp9/tests/it/conformance.rs index ceb875e0b..58a7e21ad 100644 --- a/crates/vp9/tests/conformance.rs +++ b/crates/vp9/tests/it/conformance.rs @@ -1,7 +1,7 @@ //! Bit-exactness against ffmpeg's VP9 decoder on libvpx-vp9 fixtures (skipped when ffmpeg or //! libvpx is absent). -mod common; +use crate::common; macro_rules! fixture_tests { ($($name:ident),* $(,)?) => { diff --git a/crates/vp9/tests/it/main.rs b/crates/vp9/tests/it/main.rs new file mode 100644 index 000000000..c26f9adcf --- /dev/null +++ b/crates/vp9/tests/it/main.rs @@ -0,0 +1,5 @@ +mod common; +mod conformance; +mod perf; +mod robustness; +mod synthetic; diff --git a/crates/vp9/tests/perf.rs b/crates/vp9/tests/it/perf.rs similarity index 92% rename from crates/vp9/tests/perf.rs rename to crates/vp9/tests/it/perf.rs index 162392ba1..ad427ea1a 100644 --- a/crates/vp9/tests/perf.rs +++ b/crates/vp9/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode speed (ignored by default; run with -//! `cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-vp9 --test it perf:: -- --ignored --nocapture`). -mod common; +use crate::common; use filmcraft_vp9::Decoder; use std::time::Instant; diff --git a/crates/vp9/tests/robustness.rs b/crates/vp9/tests/it/robustness.rs similarity index 99% rename from crates/vp9/tests/robustness.rs rename to crates/vp9/tests/it/robustness.rs index be63331d9..80db3d0fb 100644 --- a/crates/vp9/tests/robustness.rs +++ b/crates/vp9/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! Damaged, truncated and random input must never panic or hang (errors are fine). -mod common; +use crate::common; use filmcraft_vp9::Decoder; use std::time::{Duration, Instant}; diff --git a/crates/vp9/tests/synthetic.rs b/crates/vp9/tests/it/synthetic.rs similarity index 96% rename from crates/vp9/tests/synthetic.rs rename to crates/vp9/tests/it/synthetic.rs index a01c7a1b8..64094f800 100644 --- a/crates/vp9/tests/synthetic.rs +++ b/crates/vp9/tests/it/synthetic.rs @@ -7,7 +7,7 @@ //! show_existing_frame; //! - show_existing_frame of hidden alt-ref frames and of arbitrary slots. -mod common; +use crate::common; use std::path::Path; @@ -236,10 +236,15 @@ fn encode(name: &str, w: u32, h: u32, frames: u32, extra: &[&str]) -> Option","method":"engine.commands"} +{"id":2,"token":"","method":"engine.execute","params":{"command":"file.openDemoProject"}} +{"id":3,"token":"","method":"engine.execute","params":{"command":"playhead.set","params":{"seconds":2}}} +{"id":4,"token":"","method":"engine.execute","params":{"command":"sequence.addEdit"}} +{"id":5,"token":"","method":"engine.execute","params":{"command":"effects.apply","params":{"effect":"Gaussian Blur"}}} +{"id":6,"token":"","method":"ui.elements","params":{"prefix":"tools."}} +{"id":7,"token":"","method":"ui.click","params":{"id":"tools.Razor"}} +{"id":8,"token":"","method":"ui.key","params":{"key":"Cmd+Z"}} +{"id":9,"token":"","method":"ui.screenshot","params":{"path":"/tmp/timeline.png","panel":"Timeline"}} ``` Replies look like `{"id":4,"ok":true,"result":{"cuts":2}}` or `{"id":7,"ok":false,"error":"…"}`. Minimal client: ```python -import json, socket +import json, os, socket s = socket.create_connection(("127.0.0.1", 9876)); f = s.makefile("rw") def call(method, **params): - f.write(json.dumps({"id": 1, "method": method, "params": params}) + "\n"); f.flush() + f.write(json.dumps({"id": 1, "token": os.environ["FILMCRAFT_CONTROL_TOKEN"], "method": method, "params": params}) + "\n"); f.flush() return json.loads(f.readline()) print(call("engine.execute", command="sequence.inspect")) ``` diff --git a/docs/contributing.md b/docs/contributing.md index c7af17706..b1e1222c7 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -39,6 +39,7 @@ cargo xtask web --serve 8765 # the web app on http://12 | Variable | Effect | |---|---| | `FILMCRAFT_CONTROL_PORT` | Same as `--control ` | +| `FILMCRAFT_CONTROL_TOKEN` | Required for `--control`: 64 random hex characters, inherited by authorized clients; [setup](control-protocol.md) | | `FILMCRAFT_CPU_COMPOSITE=1` | Disables the GPU compositor | | `FILMCRAFT_DEV_DESKTOP_ENTRY=1` | Linux, opt-in: a run from a source tree installs a user-level desktop entry (and the icons from `assets/app-icon`) so the taskbar shows the FilmCraft icon. Nothing is written without it; packaged runs never write one | | `FILMCRAFT_DATA_DIR` | Same as `--data-dir

`: auto-save, crash recovery, settings and logs | @@ -88,7 +89,8 @@ Regular, to keep the wasm small). The Japanese ones become the last fallback of (`crates/ui-egui/src/theme.rs`, BIZ UDPGothic first) and fallback faces in the text engine (Gothic for sans text, Mincho for serif text). Without `CRAFT_FONTS_DIR`, `CRAFT_FONTS` is empty and FilmCraft uses its bundled and system fonts. A `CRAFT_FONTS_DIR` that is not a checkout is a build -warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both). Tests on these fonts' +warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both). With +`CRAFT_FONTS_REQUIRED=1`, a build without `CRAFT_FONTS_DIR` is an error too. Tests on these fonts' glyphs skip when it is empty; when you touch fonts, run the gates both with and without it. Chinese media names and track labels use the `Hans` / `Hant` craft-fonts faces after the Japanese @@ -116,11 +118,11 @@ Every commit must pass all of these: |---|---| | Format | `cargo fmt --check` (`rustfmt.toml`: `max_width = 160`) | | Lints | `cargo clippy --workspace --all-targets -- -D warnings` | -| Tests | `cargo test --workspace` | +| Tests | `cargo nextest run --workspace` ([cargo-nextest](https://nexte.st), or `cargo test --workspace`), then `cargo test --workspace --doc` | | Layering | `cargo xtask layers` | | Assets | `cargo xtask assets` | | wasm | `cargo xtask wasm` (checks every L0–L4 crate, `filmcraft-ui-egui` and `filmcraft-web` for `wasm32-unknown-unknown`) | -| All of the above | `cargo xtask ci` (runs clippy and tests with `--release`) | +| All of the above | `cargo xtask ci` | Run them with `CRAFT_FONTS_DIR` set too when you change font code ([craft-fonts](#building-with-craft-fonts)). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 80802cf20..9cdb36791 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -2,6 +2,22 @@ ## Desktop control channel `filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT`) listens on `127.0.0.1:` (loopback only). +The listener requires `FILMCRAFT_CONTROL_TOKEN`: 64 random hex characters shared with authorized +clients through their inherited environment. Generate a fresh token for each desktop session, then +start the desktop and bridge from that environment: + +```sh +export FILMCRAFT_CONTROL_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" +filmcraft --control 9876 & +filmcraft-cli mcp --bridge 127.0.0.1:9876 +``` + +The CLI / MCP bridge reads this variable automatically. Keep it private: don't put the value in +command-line arguments, logs, checked-in MCP configuration, or shared files. Without a valid value, +the desktop leaves the listener closed. Every raw JSON request must include the same top-level +`token` string; missing or incorrect credentials get an error and close the connection before any +request is dispatched. Loopback alone does not restrict clients to the desktop owner's OS account. + One JSON request per line → one JSON reply per line. The app opts out of macOS App Nap (`apps/filmcraft/src/app_nap.rs`): a hidden window would otherwise drop the whole process to background priority, and on a busy machine it would stop answering. @@ -13,11 +29,11 @@ nothing sent after it on that connection runs. That covers text that isn't JSON, an object without `method`, invalid UTF-8, and a line longer than 4 MiB. An HTTP request (for example a web page's cross-origin `fetch` to `127.0.0.1:`) therefore can't smuggle a command in its body: its request line is rejected first. At most 16 connections are served at once; further ones get an error -line and are closed. Clients that get an error reply should reconnect. The port has no authentication, -so only enable it while you use it. Transport: `apps/filmcraft/src/control_server.rs`. +line and are closed. Clients that get an error reply should reconnect. Transport: +`apps/filmcraft/src/control_server.rs`. ```json -{"id": 1, "method": "engine.execute", "params": {"command": "sequence.addEdit", "params": {"seconds": 3}}} +{"id": 1, "token": "", "method": "engine.execute", "params": {"command": "sequence.addEdit", "params": {"seconds": 3}}} {"id": 1, "ok": true, "result": {"cuts": 2}} ``` diff --git a/docs/gaps.md b/docs/gaps.md index 428c3bddd..d3f32c632 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -119,7 +119,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G6. No real-world media corpus or workflow acceptance tests -- **Done:** first scripted MCP workflows (#835, `apps/filmcraft-cli/tests/workflows.rs`): the +- **Done:** first scripted MCP workflows (#835, `apps/filmcraft-cli/tests/it/workflows.rs`): the `filmcraft-cli mcp` binary imports ffmpeg-made phone-like VFR H.264 + AAC MP4 and long-GOP H.264 Matroska with two audio tracks (AAC, AC-3), edits (extract, audio gain, title; ripple trims, colour matte, cross dissolve), exports H.264 MP4, and saves a project that a new process diff --git a/docs/keyboard.md b/docs/keyboard.md index 7e655b4ef..a9eade807 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -11,7 +11,7 @@ commands (frames, panels, monitor zoom, Project and Text panel navigation) run t channel (`engine.execute` / `ui.menu.invoke`) and MCP (`command_run`). Tests: `crates/engine/src/keyboard_tests.rs` (no key used twice in one scope; behaviour of the -keyboard commands, with undo) and `crates/ui-egui/tests/keyboard_ui.rs` (no dangling ids in the +keyboard commands, with undo) and `crates/ui-egui/tests/it/keyboard_ui.rs` (no dangling ids in the tables, no conflicts in FilmCraft Default or the Premiere preset, keys pressed in the headless app). ## Keyboard-only commands (M3.12) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 2cf2ca4fc..6760217e7 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -48,19 +48,20 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | Arabic | ar | 0 (0%) | — | **no RTL interface layout**; fallback fonts and shaping in the interface, captions in reading order, other text in egui's left-to-right run order; titles broken (#395) | — | none | 40–70 h | | French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review | | Portuguese (Brazil) | pt-br | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#859) | review | -| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review | +| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review | | Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review | -| German | de | 3,590 (same entries as Spanish); must cover the core menus and the macOS app menu, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | menus yes (#630); the rest not recorded, welcome | partial | review | +| German | de | 3,590 (same entries as Spanish) | as Spanish | Latin (bundled fonts) | menus yes (#630); the rest not recorded, welcome | partial | review | | Korean | ko | 3,573 (same entries as Spanish, plus the macOS Settings item) | as Spanish | installed system Hangul font (Malgun Gothic, Apple SD Gothic Neo, Noto Sans CJK KR…); refused without one | not recorded; welcome | partial (#508) | review; Hangul in craft-fonts for the web | | Vietnamese | vi | 0 (0%) | — | Latin with stacked diacritics (font coverage to check) | — | none | 6–12 h + review | -| *Other shipped:* Italian | it | 3,573 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review | +| *Other shipped:* Italian | it | 3,573 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review | | Czech | cs | 3,597 (all Spanish source keys, plus the playback-resolution label `1/2`) | dialogs, Settings, effects, shortcuts, status; engine errors English | Latin (bundled fonts) | not reviewed | partial | native review | | *Other shipped:* Ukrainian | uk | 351 (10.0%) | menus only | Cyrillic (bundled) | not recorded | menus only | 5–9 h | UI strings are catalog entries (the non-comment lines of `src/i18n/.tsv`; English counts the source strings in the code), so the rows were measured at different times: at this change the -Spanish and German catalogs both have 3,590 entries, German every Spanish one. Strings added -later may stay English in German until translated; only the core menus are required. +Spanish and German catalogs both have 3,590 entries, German every Spanish one. A string a catalog +lacks shows in English; no test or build requires a translation. `cargo xtask i18n-report` (also +printed by `cargo xtask ci`) lists each language's missing strings. Premiere also ships Italian, which we now cover like Spanish, and Russian (6–10 h). @@ -93,6 +94,7 @@ creative effect names, and the shared `Subtitle` key (caption format and graphic | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Missing translations never fail a test or build (they show in English); `cargo xtask i18n-report`, also run by `cargo xtask ci`, lists them per language | | 2026-10-19 | minor | Added Czech (`cs`) interface catalog and locale detection; rewrote all catalog rows with a binding glossary; AI-assisted, not yet proofread by a native speaker | | 2026-10-13 | minor | German (de) covers the whole interface: every entry of the Spanish catalog (3,590); the tests require the core menus and the macOS app menu and reject entries without a Spanish one | | 2026-10-11 | minor | German (de) ships the menus (#630): 371 of 3,498 strings (10.6%), native-reviewed; dimension now 2 menus-only, 5 none | diff --git a/docs/monitors.md b/docs/monitors.md index c0120112a..ae9408a26 100644 --- a/docs/monitors.md +++ b/docs/monitors.md @@ -54,7 +54,7 @@ Commands: `source.playback.play`, `source.playback.stop`, `source.playback.toggl available through the UI command registry, CLI bridge and MCP. `playback.toggle`, `playback.forward`, `playback.reverse`, `playback.slowForward` and `playback.slowReverse` also accept `monitor: "source"|"program"` to override focus. `ui.inspect.sourcePlayback` reports playing, -audioClock and playhead. Test: `cargo test --release -p filmcraft-ui-egui --test source_playback_ui`. +audioClock and playhead. Test: `cargo test --release -p filmcraft-ui-egui --test it source_playback_ui::`. ## Drag a marked Source range to the timeline @@ -75,7 +75,7 @@ Automation ids: `source.drag.video`, `source.drag.audio`, `source.drag.both`, `s and `source.waveform`. Placement dispatches `timeline.place` with `sourceIn`, `duration`, `video` and `audio`; the stream flags default to true for existing callers. Engine tests cover range bounds, stream choices, linked placement, invalid parameters and undo/redo. UI tests exercise -actual drags: `cargo test --release -p filmcraft-ui-egui --test source_drag_ui`. +actual drags: `cargo test --release -p filmcraft-ui-egui --test it source_drag_ui::`. Ctrl+Shift+X (Cmd+Shift+X on macOS) clears In/Out on the focused Source clip, including split points, and restores the implicit full span. It is undoable and leaves Program marks unchanged. diff --git a/docs/performance.md b/docs/performance.md index c2bed4d95..65310442a 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -19,7 +19,7 @@ search highlights by query. Playhead movement reuses them; project edits, undo/r refresh them. Highlight lookup is constant work per word, including searches with many matches. To measure a long transcript, run -`cargo test -p filmcraft-ui-egui --test transcript_ui long_transcript_redraw_benchmark -- --ignored --nocapture` +`cargo test -p filmcraft-ui-egui --test it transcript_ui::long_transcript_redraw_benchmark -- --ignored --nocapture` alone. It reports median and p95 redraw time for 10,000 words with an empty query and a query matching every word. It measures UI update work, not native display presentation, and deliberately has no machine-dependent timing assertion. The transcript still lays out every word; list virtualization @@ -181,7 +181,7 @@ Same commit, Settings ▸ Playback ▸ Hardware decoding switched with the bench `--hw auto` (`cargo xtask bench --sections decode --only dec_h --repeat 3 --hw …`, then `--sections playback --only h264` and `--only hevc-2160`), 2026-10-05, Apple M4 Pro, load average **150–190** throughout (shared with other agent builds). The decoded pictures are identical either -way (bit-exact parity tests, `crates/platform/tests/videotoolbox.rs`); only who decodes changes. +way (bit-exact parity tests, `crates/platform/tests/it/videotoolbox.rs`); only who decodes changes. ### Decode (every frame through the media stack) @@ -390,7 +390,7 @@ rounds; the table gives the range of the two (decode: best of 3 within each). Re `cargo xtask bench --sections decode --only dec_h264 --repeat 3 --hw off|auto` (likewise `dec_hevc`) and `--sections playback --only "h264-2160 full" --hw off|auto`; set `FILMCRAFT_FFMPEG` if ffmpeg is not on `PATH` for the fixture generator. Pictures are identical either way (bit-exact parity tests, -`crates/platform/tests/media_foundation.rs`). The decoder MFTs were Microsoft's H.264 decoder +`crates/platform/tests/it/media_foundation.rs`). The decoder MFTs were Microsoft's H.264 decoder (`Microsoft H264 Video Decoder MFT`) and the `HEVCVideoExtension` decoder, both decoding with DXVA on the RTX 5060's NVDEC: `nvidia-smi dmon -s u` showed the decoder engine at 18–27 % during an Auto run and 0 % otherwise, and every picture came back as a Direct3D 11 texture. @@ -553,7 +553,7 @@ one thread the code path is the old one. Linux, AMD Ryzen 7 7800X3D (8 cores / 16 threads), 2026-10-11, shared with other agents' builds; base = `2ff3f5e`, after = this change, 16 decoder threads, SVT-AV1 preset 8 fixtures from -`crates/av1/tests/perf.rs` (`perf_1080p_lowdelay` and `perf_2160p_gop` are new there). +`crates/av1/tests/it/perf.rs` (`perf_1080p_lowdelay` and `perf_2160p_gop` are new there). `av1dec` example (`AV1_LOOPS=2 AV1_THREADS=16`), 8 rounds alternating base / after per fixture, load average 11–27; speed-up = base wall time / after wall time of the adjacent pair: @@ -693,7 +693,7 @@ copy. the loop filter; HEVC sub-layer non-reference pictures of the top sub-layer skip deblocking and SAO; AV1 shown frames that refresh no slot skip deblocking, CDEF and loop restoration. Tested per codec (unflagged pictures bit-exact, single- and multi-threaded) and through the - media stack (`crates/codecs/tests/draft_decoders.rs`: draft frames reach draft requests only; + media stack (`crates/codecs/tests/it/draft_decoders.rs`: draft frames reach draft requests only; an export after draft playback is bit-exact with ffmpeg). Not done: AV1 row-level frame threading (references are waited for as whole frames) and its diff --git a/docs/project-files.md b/docs/project-files.md index 3ca0bc0be..7878eadc5 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -125,9 +125,10 @@ like Premiere's: | Update it at least every N second(s) | `autoSave.recoveryIntervalSeconds` | 5 (1–600) | At each interval, if the project changed since the last auto-save, a copy is written to the -`Auto-Save` folder next to the project as `-YYYY-MM-DD_HH-MM-SS.fcproj` (local time). An +`Auto-Save` folder next to the project as `-YYYY-MM-DD_HH-MM-SS-.fcproj` (local time, then the real time that orders +the copies, so a clock change at the end of daylight saving time cannot reorder them). An unsaved project uses `/Auto-Save/`. After each write the oldest copies beyond the limit are -deleted. Only files named exactly `-.fcproj` are ever deleted. An auto-save is an +deleted. Only files named exactly `-.fcproj` or `--.fcproj` are ever deleted. An auto-save is an ordinary project file: open it with File ▸ Open Project. `file.listAutoSaves` lists them, newest first. diff --git a/docs/releasing.md b/docs/releasing.md index 58c283743..d3f482b41 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -59,8 +59,8 @@ signing jobs are refused, and the draft-release job, which needs them, is skippe `filmcraft --version` and `filmcraft-cli --version` print the version from `Cargo.toml`. **Fonts.** Every build job checks out [craft-fonts](https://github.com/storytold/craft-fonts) at a -pinned commit (the `ref:` of its craft-fonts checkout step, marked `# bump deliberately`) and builds -with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed its Japanese fonts and fail +pinned commit (`craft-fonts-ref` in `.github/workflows/release.yml` and `freebsd.yml`) and builds +with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail rather than ship without them (the web build embeds only the UI font, to stay small). The packages carry each embedded font's licence (`copy_font_licences` in `packaging/env.sh`). Bump the pins deliberately, in every job at once. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3484e30a2..6f419b182 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap detail -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: milestones moved from ROADMAP.md, Current focus rewritten from the 2026-10-10 re-measure, beta milestones added) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (M8: Fujifilm F-Log / F-Log2 / F-Log2 C colour spaces) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Forward-looking plan: milestones with status, the current focus and what's next, with estimates in Opus 5.5 agent-hours (calibration in [target-app-parity.md](target-app-parity.md#calibration)). @@ -68,7 +68,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining | M5 | Effects, keyframes, GPU | 🟡 | All 93 Premiere 26 video effects and 84 transitions (CPU), blend modes and 33 common effects on the GPU (#32, GPU2, GPU4, GPU5), keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | More effects on the GPU (34 ids done incl. Lumetri basic / creative / vignette; GPU export landed #421), GPU masks in the live path, exact Warp Stabilizer (3-D) and Morph Cut | 40–70 | | M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 or QuickTime + own AAC; ProRes, DNxHR, MJPEG, PNG/TIFF/BMP sequences (Premiere-style numbering), GIF, WAV, AIFF; background jobs; M6.5: Export mode parity (frame size / rate / scaling / pixel aspect, profile / level, CBR / VBR 1- and 2-pass, keyframe distance, audio codec / rate / channels / bitrate / sample size, multiplexer, burn-in or sidecar captions, image / name / timecode overlays, video limiter, loudness normalization with true-peak limiter, metadata, ranges, estimated size, summary), 24 built-in presets + user presets with favourites and import/export (Preset Manager), export queue (reorder, cancel, retry, several sequences / ranges), Quick Export; `export.*` commands and `filmcraft-cli export --preset` | Interlaced encoding, 5.1 audio, smart render, publishing destinations | 6–8 | | M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 | -| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 | +| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log, Fujifilm F-Log / F-Log2 / F-Log2 C from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 | | M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4), DNxHD/DNxHR decoder (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2/4:4:4, interlaced, alpha; within IDCT precision of ffmpeg on 22 fixtures) + DNxHR LB/SQ/HQ/HQX/444 encoder and MOV `AVdh` export, AV1 decoder (Main profile, 8/10-bit, all intra/inter tools, loop filters, superres, film grain, intra BC, spatial layers; bit-exact vs libdav1d on 19 SVT fixtures + 22 libaom vectors; MP4 `av01` / WebM `V_AV1` import), MXF import (OP1a/OP-Atom; AVC with POC-ordered B pictures bit-exact, DNxHD/DNxHR, ProRes, PCM/AES3; MPEG-2 reported unsupported; index-table seeking, material package timecode), Ogg Opus/Vorbis (granule seeking, pre-skip, end trimming), image sequences, Broadcast WAV timecode | VP9 frame threading, AV1 threading/SIMD (~4 fps 1080p today), AV1 High/Professional profiles, H.264 High 10 / 4:2:2 and HEVC 4:2:2 in software (G3), E-AC-3, AVI (MPEG-2 decoder and hardware decode have landed) | 30–50 | | M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute; graphics templates (own `.fcgt` format, 8 original built-ins, Browse tab with engine-rendered thumbnails, export / install / apply / editable properties; never reads .mogrt), rolls/crawls with ease and pre/postroll, responsive pins and intro/outro, per-character styles, Upgrade Caption to Graphic, source graphics, Replace Fonts; MCC (608+708), EBU STL, TTML/IMSC1, DFXP (frame-exact, property-tested) | 608/708 embedding in video streams, speech-to-text | 12–18 | | M11 | Interchange & project management | ✅ | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO; offline media (own slate) + Link Media (fingerprint-checked relink, folder remap, search, Align Timecode, Make Offline); proxies (ProRes Proxy/LT, H.264 ¼/½ background jobs, attach/detach/reconnect, monitor toggle, export full-res) + ingest (copy/transcode/proxies); Project Manager (collect, consolidate + transcode with handles, size estimate) | Rename media to clip names, image-sequence conversion, Media Browser-driven relink, smart (cross-drive) path tracking | 1–2 | diff --git a/docs/testing.md b/docs/testing.md index 67d312fad..77ed53f0d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,6 +2,7 @@ ```sh cargo test --workspace # everything; oracle tests skip without ffmpeg +cargo nextest run --workspace # the same in parallel processes (doctests: cargo test --doc) cargo test -p filmcraft-h264 # one crate cargo test --release -p filmcraft-hevc # codec tests are much faster in release cargo xtask ci # all gates (fmt, clippy, tests, layers, assets, wasm) @@ -9,34 +10,41 @@ cargo xtask fixtures # pre-generate the ffmpeg fixtur FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails instead of skipping ``` +Integration tests build one binary per crate: a crate with several test files keeps them in +`tests/it/`, and `tests/it/main.rs` declares each as a module. Add a test file there with a `mod` +line in `main.rs`; shared helpers live in `tests/it/common/` or `tests/it/support/`. A test file that +changes process-wide state (a global switch, pool, panic hook or fault injection) or times a workload +stays a binary of its own in `tests/`. Select one file's tests with its module path: +`cargo test -p filmcraft-h264 --test it conformance::`. + ## 1. Kinds of tests | Kind | Where | Examples | |---|---|---| | Unit | `src/` `#[cfg(test)]` modules, `src/tests.rs` | CABAC engines, VLC tables (prefix-freeness, Kraft sums), colour matrices, frame cache | -| Property (`proptest`) | `time`, `bitstream`, `edit`, `isobmff/tests/roundtrip.rs`, `interchange/tests/*` | tick↔frame round trips for every rate, DF timecode, edit invariants (no overlaps, durations conserved), mux→demux round trips, interchange export→import | +| Property (`proptest`) | `time`, `bitstream`, `edit`, `isobmff/tests/it/roundtrip.rs`, `interchange/tests/it/*` | tick↔frame round trips for every rate, DF timecode, edit invariants (no overlaps, durations conserved), mux→demux round trips, interchange export→import | | Synthetic streams | `h264/src/synth_tests.rs`, `hevc/src/synth_tests.rs` | hand-built bitstreams for features the reference encoders don't emit; expected output known exactly | -| Scripted UI | `crates/ui-egui/tests/scripted.rs` | headless app (egui_kittest) driven over the control channel: razor/undo, insert, apply effect, playback, click by automation id (§4) | -| Monitor view / Graphics menu | `crates/ui-egui/tests/view_ui.rs`, `crates/engine/src/graphics_tests.rs` | View menu items and checkmarks, playback/paused resolution, channel / comparison / multi-camera / waveform display modes, magnification and Hand-tool panning, guides dragged from the rulers (move, lock, remove, Add Guide…, templates in the preferences), snapping a graphic to the frame centre and a guide; align to frame / as group / to selection, distribute (centres and gaps), arrange, select next/previous graphic and layer, reset parameters / duration, vertical text, New Layer from file (`FILMCRAFT_UI_SHOTS=` writes `view-*.png`) | -| Settings | `crates/engine/src/settings_tests.rs`, `crates/ui-egui/tests/settings_ui.rs` | schema keys and defaults, persistence in the data directory, v1 → v2 migration and repair of bad values, validation, per-category reset; each wired setting (still / transition durations, step many, label defaults and colours, media scaling, timebase, recent projects, media cache policy, output mapping, smart quotes, auto-transcribe); the dialog: every category from its command and Cmd+,, edits by automation id, OK / Cancel / Escape / Reset…, theme, label names in Edit ▸ Label, tooltips, frame cache (`FILMCRAFT_UI_SHOTS=` writes `settings-*.png`) | -| Scopes and panels (M8.9 / M12.6) | `crates/scopes/src/tests.rs`, `crates/engine/src/{scopes,panels}_tests.rs`, `crates/ui-egui/tests/panels_ui.rs` | scope maths on generated frames (flat colours → exact histogram bins, ramps → waveform row = code value, 75 % bars → parade levels and vectorscope target cells for BT.601/709/2020, HLS hue angles, YC chroma, Clamp Signal, decimation, NaN); `scopes.read` on a colour matte (exact bins, 78.43 %) and on Bars and Tone (the six targets); metadata edits (one undo step, read-only fields refused, saved in the project), the event log (failed / disabled commands, repeats, jobs started / finished / failed / cancelled); every panel from Window ▸, the scopes' wrench menu, presets, five-scope grid, typing into a Metadata field, Timecode rows and modes, Events filter / Clear All, Progress cancel, Reference Monitor park / gang / scopes (`FILMCRAFT_UI_SNAPSHOT_DIR=` writes `panels-*.png`); `perf_scopes_at_1080p` (ignored) times each scope | -| Keyframe navigator and Effect Controls ruler | `crates/engine/src/tests.rs` (`add_keyframe_toggles_the_keyframe_at_the_playhead`), `crates/ui-egui/tests/keyframes_ui.rs`, `lane_tests` in `crates/ui-egui/src/panels/effect_controls.rs` | `effects.addKeyframe` adds a keyframe at the playhead or removes the one there (the value falls back to the remaining keyframes; undo, redo, parameters that name nothing); headless: the Properties diamond turns animation on, adds and removes the keyframe at the playhead (Scale 50 % → 100 % again), its ◀ ▶ arrows and Effect Controls' step between keyframes, Crop is applied before its first keyframe; a click on the Effect Controls time ruler and a drag of the playhead's handle move the playhead, and no playhead is drawn off the clip; ruler labels are sequence timecode, and clip times, frame rates and panel widths at their limits don't panic (`FILMCRAFT_UI_SHOTS=` writes `keyframes-*.png`) | +| Scripted UI | `crates/ui-egui/tests/it/scripted.rs` | headless app (egui_kittest) driven over the control channel: razor/undo, insert, apply effect, playback, click by automation id (§4) | +| Monitor view / Graphics menu | `crates/ui-egui/tests/it/view_ui.rs`, `crates/engine/src/graphics_tests.rs` | View menu items and checkmarks, playback/paused resolution, channel / comparison / multi-camera / waveform display modes, magnification and Hand-tool panning, guides dragged from the rulers (move, lock, remove, Add Guide…, templates in the preferences), snapping a graphic to the frame centre and a guide; align to frame / as group / to selection, distribute (centres and gaps), arrange, select next/previous graphic and layer, reset parameters / duration, vertical text, New Layer from file (`FILMCRAFT_UI_SHOTS=` writes `view-*.png`) | +| Settings | `crates/engine/src/settings_tests.rs`, `crates/ui-egui/tests/it/settings_ui.rs` | schema keys and defaults, persistence in the data directory, v1 → v2 migration and repair of bad values, validation, per-category reset; each wired setting (still / transition durations, step many, label defaults and colours, media scaling, timebase, recent projects, media cache policy, output mapping, smart quotes, auto-transcribe); the dialog: every category from its command and Cmd+,, edits by automation id, OK / Cancel / Escape / Reset…, theme, label names in Edit ▸ Label, tooltips, frame cache (`FILMCRAFT_UI_SHOTS=` writes `settings-*.png`) | +| Scopes and panels (M8.9 / M12.6) | `crates/scopes/src/tests.rs`, `crates/engine/src/{scopes,panels}_tests.rs`, `crates/ui-egui/tests/it/panels_ui.rs` | scope maths on generated frames (flat colours → exact histogram bins, ramps → waveform row = code value, 75 % bars → parade levels and vectorscope target cells for BT.601/709/2020, HLS hue angles, YC chroma, Clamp Signal, decimation, NaN); `scopes.read` on a colour matte (exact bins, 78.43 %) and on Bars and Tone (the six targets); metadata edits (one undo step, read-only fields refused, saved in the project), the event log (failed / disabled commands, repeats, jobs started / finished / failed / cancelled); every panel from Window ▸, the scopes' wrench menu, presets, five-scope grid, typing into a Metadata field, Timecode rows and modes, Events filter / Clear All, Progress cancel, Reference Monitor park / gang / scopes (`FILMCRAFT_UI_SNAPSHOT_DIR=` writes `panels-*.png`); `perf_scopes_at_1080p` (ignored) times each scope | +| Keyframe navigator and Effect Controls ruler | `crates/engine/src/tests.rs` (`add_keyframe_toggles_the_keyframe_at_the_playhead`), `crates/ui-egui/tests/it/keyframes_ui.rs`, `lane_tests` in `crates/ui-egui/src/panels/effect_controls.rs` | `effects.addKeyframe` adds a keyframe at the playhead or removes the one there (the value falls back to the remaining keyframes; undo, redo, parameters that name nothing); headless: the Properties diamond turns animation on, adds and removes the keyframe at the playhead (Scale 50 % → 100 % again), its ◀ ▶ arrows and Effect Controls' step between keyframes, Crop is applied before its first keyframe; a click on the Effect Controls time ruler and a drag of the playhead's handle move the playhead, and no playhead is drawn off the clip; ruler labels are sequence timecode, and clip times, frame rates and panel widths at their limits don't panic (`FILMCRAFT_UI_SHOTS=` writes `keyframes-*.png`) | | Golden images | `crates/golden/tests/golden.rs` | CPU renders vs committed PNGs; GPU vs CPU on the same scenes (§3) | | Engine / command | `crates/engine/src/tests.rs` | run commands on the demo project, assert the sequence, undo/redo, disabled cases | | Render | `crates/render/src/tests.rs` | compositing, opacity, Motion, cross dissolve midpoint, ½-res vs full, GPU plan vs reference, audio mix, audio-effect continuity | -| Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; 20 angles: paging (auto / 2×2 / 3×3), page-relative keys, absolute cameras beyond 16, a page decoding only its angles at the cell scale (recording source), Auto-Adjust scale, Selection Top Down; headless: page arrows, angle 17–20 cells, grid-only view, Edit Cameras thumbnails; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost | -| Project panel and Media Browser (M12.7) | `crates/engine/src/{project_panel,media_browser}_tests.rs`, `crates/ui-egui/tests/project_panel_ui.rs` | view settings and their preference round trip; Metadata Display columns (custom fields, Freeform keys hidden), numeric vs text sorting, view presets (save / save as / restore / rename / delete, ten slots, persisted in `preferences.json`); Freeform moves keeping offsets, snapping, Align to Grid, Clip Size, stacks, arrangements, Reset (each one undo step, saved in the project); bin rename / nesting; the Media Browser against a fake filesystem (folders first, hidden and unsupported files left out, file-type filter, back / forward / up, recent directories, Favorites, import of a selection or a folder, Open in Source Monitor, ingest, probe columns). Headless: List view (sort by header, drag a column wider, inline rename), Icon view (hover scrub + I / O set the clip's In / Out, Shift+] / [), Freeform (drag a card, stack from the card menu, options dialog), panel menu, Metadata Display dialog, view presets, bins in place / tab / window, footer buttons, Media Browser navigation, Favorites, import, Shift+O, Edit Columns…, thumbnails, drag to the Timeline (`FILMCRAFT_UI_SNAPSHOT_DIR=` writes `project-*.png`, `media-browser-*.png`) | -| Menu long tail (M3.11) | `crates/render/src/scene.rs`, `crates/engine/src/{scene_detect,sequence_extras,project_tools}_tests.rs`, `crates/interchange/src/ale.rs`, `crates/project/src/find.rs`, `crates/ui-egui/tests/menus_ui.rs` | Scene Edit Detection on synthetic frames (hard cuts vs pans, flashes, sensitivity) and on generated movies with known cut points (our own ProRes render of demo scenes; an ffmpeg-made H.264 concat of testsrc2 / SMPTE bars / Mandelbrot): cuts with linked audio, clip markers, subclips, one undo step, background job and cancel; Normalize Mix Track to a target peak; Simplify Sequence; Find / Find Next and search bins; Automate to Sequence (overlap, transitions, insert, unnumbered markers); ALE round trip; Project Settings and scratch disks; menu order and shortcuts against Premiere's; the dialogs by automation id | +| Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/it/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; 20 angles: paging (auto / 2×2 / 3×3), page-relative keys, absolute cameras beyond 16, a page decoding only its angles at the cell scale (recording source), Auto-Adjust scale, Selection Top Down; headless: page arrows, angle 17–20 cells, grid-only view, Edit Cameras thumbnails; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost | +| Project panel and Media Browser (M12.7) | `crates/engine/src/{project_panel,media_browser}_tests.rs`, `crates/ui-egui/tests/it/project_panel_ui.rs` | view settings and their preference round trip; Metadata Display columns (custom fields, Freeform keys hidden), numeric vs text sorting, view presets (save / save as / restore / rename / delete, ten slots, persisted in `preferences.json`); Freeform moves keeping offsets, snapping, Align to Grid, Clip Size, stacks, arrangements, Reset (each one undo step, saved in the project); bin rename / nesting; the Media Browser against a fake filesystem (folders first, hidden and unsupported files left out, file-type filter, back / forward / up, recent directories, Favorites, import of a selection or a folder, Open in Source Monitor, ingest, probe columns). Headless: List view (sort by header, drag a column wider, inline rename), Icon view (hover scrub + I / O set the clip's In / Out, Shift+] / [), Freeform (drag a card, stack from the card menu, options dialog), panel menu, Metadata Display dialog, view presets, bins in place / tab / window, footer buttons, Media Browser navigation, Favorites, import, Shift+O, Edit Columns…, thumbnails, drag to the Timeline (`FILMCRAFT_UI_SNAPSHOT_DIR=` writes `project-*.png`, `media-browser-*.png`) | +| Menu long tail (M3.11) | `crates/render/src/scene.rs`, `crates/engine/src/{scene_detect,sequence_extras,project_tools}_tests.rs`, `crates/interchange/src/ale.rs`, `crates/project/src/find.rs`, `crates/ui-egui/tests/it/menus_ui.rs` | Scene Edit Detection on synthetic frames (hard cuts vs pans, flashes, sensitivity) and on generated movies with known cut points (our own ProRes render of demo scenes; an ffmpeg-made H.264 concat of testsrc2 / SMPTE bars / Mandelbrot): cuts with linked audio, clip markers, subclips, one undo step, background job and cancel; Normalize Mix Track to a target peak; Simplify Sequence; Find / Find Next and search bins; Automate to Sequence (overlap, transitions, insert, unnumbered markers); ALE round trip; Project Settings and scratch disks; menu order and shortcuts against Premiere's; the dialogs by automation id | | Mixer | `crates/render/src/mixer_tests.rs`, `crates/engine/src/mixer_tests.rs` | sample-exact fader gain and pan laws, automation at block boundaries (bit-identical however requests are cut), solo/mute/solo-safe, sends and submix routing, latency-compensation alignment, render-vs-playback identity, Touch ramp-back, recorder modes (Latch/Touch/Write), thinning, Audio Gain, transition curves; `perf_24_tracks_3_effects_realtime_factor` prints the realtime factor | -| 5.1 / multichannel | `crates/audio-dsp/src/channels.rs`, `crates/render/src/mixer_tests.rs`, `crates/export/src/surround_tests.rs`, `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | BS.775 downmix / upmix coefficients exact, mixdown types; 5.1 panner point gains equal-power (Σg² = 1 over a 21×21 grid × 3 centre settings), speaker positions exact, 5.1 through the default puck bit-exact identity; graph: stereo/mono tracks into a 5.1 Mix (L/R, C, rear right, room centre −6 dB each, LFE = mean), 5.1 track through a 5.1 Mix and folded into stereo, a 5.1 submix, `pan51` automation block-invariant, six-channel meters; export: 6-ch WAV (`WAVE_FORMAT_EXTENSIBLE`, mask 0x3F) and ProRes MOV (6 × 24-bit PCM) decoded by ffmpeg **sample-exact**, ffprobe `channels` 6 / `channel_layout` 5.1; AAC 5.1 in MP4: every decoded channel's own tone ≥ 30 dB above the others' (no swapped channels), stereo export of a 5.1 Mix = BS.775 within 2·10⁻⁶; `sequence.settings {mix}`, `file.newSequence {mix, trackType}`, panner lanes, Write pass records the puck; UI: puck drag (one undo step), Center / LFE, six meter bars | -| Audio Clip Mixer automation, Track Mixer view | `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | Touch: a 201-point fader ramp thins to ≤ 6 clip keyframes, values within 0.1 dB, live override while held, values outside the gesture unchanged, one undo step; Latch holds until stop; Write records volume and pan from the start; a gesture across two clips writes both; Read commits once without a pass; UI: Clip Mixer mode dropdown, a recorded fader drag writes clip keyframes; Show/Hide Tracks, Meter Input(s) Only, transport record button | -| Voice-over | `crates/engine/src/voiceover_tests.rs`, `crates/ui-egui/tests/voiceover_ui.rs` | synthetic click train lands in `mix_sequence` exactly at R + k·period (pre-roll dropped, exact sample count), punch-in between In/Out (ramp source, every sample checked), one undo step + undo/redo, take numbering, record-arm/targeting track choice, sync/discard/zero-length/locked cases, cue times; UI: right-click Mic → Voice-Over Record Settings dialog (edits persist on OK, Cancel discards), the Mic button starts and stops a take on its track with pre-roll and overlay, and records past the end of the timeline (also `voiceover_playback_tests` in `crates/ui-egui/src/lib.rs`) | -| Text to Speech | `crates/tts/src/**/*.rs` (Kokoro tests need `FILMCRAFT_KOKORO_DIR`), `crates/tts-text`, `crates/engine/src/narration_tests.rs`, `crates/ui-egui/tests/tts_ui.rs` | built-in voices: determinism, finite samples, exact-silence pause markers, pace scales length, measured pitch per voice and per semitone, hostile scripts; engine: narration placed at the playhead with clip length = speech (sample-exact), never overwrites (busy tracks → next free track → new track), occupied/locked/unknown track refused with no change, edit keeps start/length/speed (longer speech: whole speech in the file; shorter: file padded with exact zeros), edits write beside the previous file, undo/redo restore the project exactly, enabled only with a narration clip (or `clip`), 16 hostile parameter sets refused without change, preview changes nothing, save/reload keeps narrations, the narration is audible in `mix_sequence`; UI: type + Add pause + Add to timeline, the new clip is selected and loaded, clicking the clip on the timeline loads its script, Save edits it in place as one undo step, Advanced twirl, panel opens beside Properties; natural voices: 50-sentence Whisper-base WER 2.1–3.6 % for all 9 voices (crate README), loaders fuzzed with hostile files, `tts.render` cache, download refused without the feature | -| Remix | `crates/audio-dsp/src/remix.rs`, `crates/render/src/remix.rs`, `crates/engine/src/remix_tests.rs`, `crates/ui-egui/tests/remix_ui.rs` | generated rhythmic music (Rust-synthesised drums, bass, chord sections at 90–128 BPM, 22.05/44.1/48 kHz): tempo within 0.3 BPM, every beat within 10 ms (measured ≤ 8.9 ms); remix targets from 0.4× to 2.2× the source within one beat (measured ≤ 12 ms at default sliders), every cut on a detected beat, intro and outro kept, pieces ≥ 4 beats, deterministic; silence, tone and noise refused; rendering independent of request cuts, source-exact outside the 20 ms equal-power crossfades, no clicks; the engine's mix equals the source pieces (< 1e-5); undo, redo and revert exact; overlaps refused; Remix Properties dialog and Remix tool drag driven by automation id | -| Oracle | `crates/*/tests/*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) | -| Robustness / fuzz | `*/tests/robustness.rs`, `*/tests/fuzz.rs` | seeded mutation and truncation of real and synthetic files; nothing may panic | -| Program monitor while dragging | `crates/ui-egui/tests/perf_ui.rs` | headless: a parameter changed on every UI pass (a value dragged in Effect Controls or Properties) and a backward scrub leave the Program monitor's picture at most one step behind (it shows the frame asked for the pass before while the frame due renders; it used to freeze until the mouse rested); a frame still rendering is kept when the same frame is asked for at a newer revision, so frames slower than a refresh (CPU effects) are shown too; `perf.stats` reports the picture's frame and revision against the playhead and the project | -| Performance | `*/tests/perf.rs` (`#[ignore]`) | §5 | +| 5.1 / multichannel | `crates/audio-dsp/src/channels.rs`, `crates/render/src/mixer_tests.rs`, `crates/export/src/surround_tests.rs`, `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/it/audio51_ui.rs` | BS.775 downmix / upmix coefficients exact, mixdown types; 5.1 panner point gains equal-power (Σg² = 1 over a 21×21 grid × 3 centre settings), speaker positions exact, 5.1 through the default puck bit-exact identity; graph: stereo/mono tracks into a 5.1 Mix (L/R, C, rear right, room centre −6 dB each, LFE = mean), 5.1 track through a 5.1 Mix and folded into stereo, a 5.1 submix, `pan51` automation block-invariant, six-channel meters; export: 6-ch WAV (`WAVE_FORMAT_EXTENSIBLE`, mask 0x3F) and ProRes MOV (6 × 24-bit PCM) decoded by ffmpeg **sample-exact**, ffprobe `channels` 6 / `channel_layout` 5.1; AAC 5.1 in MP4: every decoded channel's own tone ≥ 30 dB above the others' (no swapped channels), stereo export of a 5.1 Mix = BS.775 within 2·10⁻⁶; `sequence.settings {mix}`, `file.newSequence {mix, trackType}`, panner lanes, Write pass records the puck; UI: puck drag (one undo step), Center / LFE, six meter bars | +| Audio Clip Mixer automation, Track Mixer view | `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/it/audio51_ui.rs` | Touch: a 201-point fader ramp thins to ≤ 6 clip keyframes, values within 0.1 dB, live override while held, values outside the gesture unchanged, one undo step; Latch holds until stop; Write records volume and pan from the start; a gesture across two clips writes both; Read commits once without a pass; UI: Clip Mixer mode dropdown, a recorded fader drag writes clip keyframes; Show/Hide Tracks, Meter Input(s) Only, transport record button | +| Voice-over | `crates/engine/src/voiceover_tests.rs`, `crates/ui-egui/tests/it/voiceover_ui.rs` | synthetic click train lands in `mix_sequence` exactly at R + k·period (pre-roll dropped, exact sample count), punch-in between In/Out (ramp source, every sample checked), one undo step + undo/redo, take numbering, record-arm/targeting track choice, sync/discard/zero-length/locked cases, cue times; UI: right-click Mic → Voice-Over Record Settings dialog (edits persist on OK, Cancel discards), the Mic button starts and stops a take on its track with pre-roll and overlay, and records past the end of the timeline (also `voiceover_playback_tests` in `crates/ui-egui/src/lib.rs`) | +| Text to Speech | `crates/tts/src/**/*.rs` (Kokoro tests need `FILMCRAFT_KOKORO_DIR`), `crates/tts-text`, `crates/engine/src/narration_tests.rs`, `crates/ui-egui/tests/it/tts_ui.rs` | built-in voices: determinism, finite samples, exact-silence pause markers, pace scales length, measured pitch per voice and per semitone, hostile scripts; engine: narration placed at the playhead with clip length = speech (sample-exact), never overwrites (busy tracks → next free track → new track), occupied/locked/unknown track refused with no change, edit keeps start/length/speed (longer speech: whole speech in the file; shorter: file padded with exact zeros), edits write beside the previous file, undo/redo restore the project exactly, enabled only with a narration clip (or `clip`), 16 hostile parameter sets refused without change, preview changes nothing, save/reload keeps narrations, the narration is audible in `mix_sequence`; UI: type + Add pause + Add to timeline, the new clip is selected and loaded, clicking the clip on the timeline loads its script, Save edits it in place as one undo step, Advanced twirl, panel opens beside Properties; natural voices: 50-sentence Whisper-base WER 2.1–3.6 % for all 9 voices (crate README), loaders fuzzed with hostile files, `tts.render` cache, download refused without the feature | +| Remix | `crates/audio-dsp/src/remix.rs`, `crates/render/src/remix.rs`, `crates/engine/src/remix_tests.rs`, `crates/ui-egui/tests/it/remix_ui.rs` | generated rhythmic music (Rust-synthesised drums, bass, chord sections at 90–128 BPM, 22.05/44.1/48 kHz): tempo within 0.3 BPM, every beat within 10 ms (measured ≤ 8.9 ms); remix targets from 0.4× to 2.2× the source within one beat (measured ≤ 12 ms at default sliders), every cut on a detected beat, intro and outro kept, pieces ≥ 4 beats, deterministic; silence, tone and noise refused; rendering independent of request cuts, source-exact outside the 20 ms equal-power crossfades, no clicks; the engine's mix equals the source pieces (< 1e-5); undo, redo and revert exact; overlaps refused; Remix Properties dialog and Remix tool drag driven by automation id | +| Oracle | `crates/*/tests/{,it/}*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) | +| Robustness / fuzz | `*/tests/{,it/}robustness.rs`, `*/tests/{,it/}fuzz.rs` | seeded mutation and truncation of real and synthetic files; nothing may panic | +| Program monitor while dragging | `crates/ui-egui/tests/it/perf_ui.rs` | headless: a parameter changed on every UI pass (a value dragged in Effect Controls or Properties) and a backward scrub leave the Program monitor's picture at most one step behind (it shows the frame asked for the pass before while the frame due renders; it used to freeze until the mouse rested); a frame still rendering is kept when the same frame is asked for at a newer revision, so frames slower than a refresh (CPU effects) are shown too; `perf.stats` reports the picture's frame and revision against the playhead and the project | +| Performance | `*/tests/{,it/}perf.rs` (`#[ignore]`) | §5 | Commit `*.proptest-regressions` files so failing cases are re-run. @@ -52,7 +60,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). - If a tool is missing the test prints `SKIPPED (): ffmpeg not found …` and passes. Set **`FILMCRAFT_REQUIRE_ORACLES=1`** (CI) to make every such skip a failure. In new tests use `let ff = filmcraft_testkit::require_ffmpeg!();` (also `require_ffprobe!`, `require_oracles!`). -- Some oracles need a recent ffmpeg: the APV tests (`crates/apv/tests/oracle.rs`) skip when +- Some oracles need a recent ffmpeg: the APV tests (`crates/apv/tests/it/oracle.rs`) skip when `ffmpeg -formats` does not list `apv` (ffmpeg ≥ 8.0), naming the detected version. - Fixtures are generated on first use into `/target/fixtures//` (`filmcraft_testkit::fixtures_dir`; independent of `CARGO_TARGET_DIR`, so agents with private @@ -71,12 +79,12 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). the matrix. - Fixture sources are synthetic: `testsrc2`, `mandelbrot`, SMPTE bars, noise and fades, sine tones. H.264 and HEVC fixtures need ffmpeg built with libx264 and libx265. VideoToolbox fixtures are - generated only on macOS. The Media Foundation parity tests (`crates/platform/tests/media_foundation.rs`, + generated only on macOS. The Media Foundation parity tests (`crates/platform/tests/it/media_foundation.rs`, Windows only) make their 640x360, 1080p and 2160p H.264 / HEVC / Main 10 fixtures with ffmpeg and skip without a Direct3D 11 video device or the HEVC / VP9 / AV1 codec extensions of the Microsoft Store. The VP9 and AV1 fixtures (libvpx-vp9, libaom-av1; 360p, 1080p, 2160p, hidden alt-ref frames, two GOPs) need an ffmpeg with those encoders. -The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_rgba_input.rs` for +The NVENC tests (`crates/platform/tests/it/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_rgba_input.rs` for the GPU's RGB → 4:2:0 conversion, `nvenc_hevc.rs`, `nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265, `nvenc_hevc_main10.rs` and `nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows only) skip, printing `SKIPPED`, without an NVIDIA GPU @@ -173,7 +181,7 @@ Each codec README has the full fixture matrix and the measured results. ### Scripted UI tests (headless) -`crates/ui-egui/tests/scripted.rs` runs the real `FilmcraftApp` under +`crates/ui-egui/tests/it/scripted.rs` runs the real `FilmcraftApp` under [`egui_kittest`](https://docs.rs/egui_kittest) (`build_eframe`): no window, no GPU, no OS event loop. A small `Driver` opens the demo project, sends requests through the same control channel agents use (`ControlRequest` → `control::handle`, exactly as the TCP server does) and steps egui frames until @@ -190,15 +198,15 @@ next frame by the app's own `raw_input_hook`, so clicks by automation id work he | `clicking_a_tool_button_by_automation_id` | `ui.click {id: "tools.Razor"}` changes the tool (real egui input path) | | `unknown_methods_and_commands_fail_cleanly` | errors come back as `{"ok": false}` | -`crates/ui-egui/tests/mixer_ui.rs` drives the Audio Track Mixer, Audio Clip Mixer, timeline track +`crates/ui-egui/tests/it/mixer_ui.rs` drives the Audio Track Mixer, Audio Clip Mixer, timeline track keyframes and the Audio Gain dialog by automation id and with multi-frame pointer drags (press, move over several frames, release). With `FILMCRAFT_UI_SNAPSHOT_DIR=` it also renders the window offscreen through wgpu (`Harness::render`) and writes `mixer-*.png`; this works without a visible window (for example on a locked screen, where `ui.screenshot` cannot capture). -`crates/ui-egui/tests/essential_sound_ui.rs` does the same for the Essential Sound panel (type buttons, +`crates/ui-egui/tests/it/essential_sound_ui.rs` does the same for the Essential Sound panel (type buttons, switches, a slider drag as one undo step, section bypass, Auto-Match, ducking, Browse presets; `essential-sound-*.png`). -`crates/ui-egui/tests/clip_audio_ui.rs` covers the audio of video clips (#223): the linked audio's +`crates/ui-egui/tests/it/clip_audio_ui.rs` covers the audio of video clips (#223): the linked audio's Volume, Channel Volume and Panner in Effect Controls and Properties, and the Volume line on audio clips in the Timeline (a drag is one undo step, Pen-tool keyframes, keyframe drags, a click or right-click on the line still reaches the clip; `FILMCRAFT_UI_SHOTS=` writes `volume-*.png`). @@ -214,12 +222,12 @@ and the WAV export equals it. `perf_full_dialogue_chain_realtime_factor` (ignored; run with `--release`) prints the realtime factor of all nine Dialogue effects on one clip (22× on one core). -`crates/ui-egui/tests/export_ui.rs` drives Export mode: the settings column and Summary, editing a +`crates/ui-egui/tests/it/export_ui.rs` drives Export mode: the settings column and Summary, editing a setting turning the preset into Custom, the Preset Manager (search, favourite star, save, apply, delete), the queue panel (Send to Queue, Up/Down, Cancel, Start, Retry, Clear) with real encodes, the Export button and the header's Quick Export popup (`export-*.png` snapshots). -`crates/ui-egui/tests/color_ui.rs` drives colour features the same way (`color-*.png` snapshots): +`crates/ui-egui/tests/it/color_ui.rs` drives colour features the same way (`color-*.png` snapshots): Lumetri Input/Look LUT menus and section switches, the Interpret Footage ▸ Color Management and Sequence Color Management dialogs, and the HDR scopes of a PQ sequence. @@ -258,12 +266,12 @@ engine behaviour without a window. | Benchmark | Command | |---|---| -| H.264 decode, 1080p, threads sweep | `cargo test --release -p filmcraft-h264 --test perf -- --ignored --nocapture` | -| HEVC decode, 1080p / 2160p | `cargo test --release -p filmcraft-hevc --test perf -- --ignored --nocapture` | -| ProRes decode/encode | `cargo test --release -p filmcraft-prores --test perf -- --ignored --nocapture` | +| H.264 decode, 1080p, threads sweep | `cargo test --release -p filmcraft-h264 --test it perf:: -- --ignored --nocapture` | +| HEVC decode, 1080p / 2160p | `cargo test --release -p filmcraft-hevc --test it perf:: -- --ignored --nocapture` | +| ProRes decode/encode | `cargo test --release -p filmcraft-prores --test it perf:: -- --ignored --nocapture` | | Encoder speed / PSNR / bitrate | `cargo run --release -p filmcraft-h264enc --example h264enc_synth -- …` | | Decode a real file through the media stack | `cargo run --release -p filmcraft-cli -- bench-decode file.mp4 --frames 120` | -| Coding-tool coverage of the fixtures | `cargo test --release -p filmcraft-h264 --test conformance coverage_report -- --ignored --nocapture` (same for `hevc`) | +| Coding-tool coverage of the fixtures | `cargo test --release -p filmcraft-h264 --test it conformance::coverage_report -- --ignored --nocapture` (same for `hevc`) | | **Whole-app suite**: decode fps per codec, playback, scrubbing, timeline UI, export, project save/open, peak RSS | `cargo xtask bench` ([performance.md](performance.md)) | `cargo xtask bench` (the example `crates/ui-egui/examples/bench/`) runs six sections, each in its diff --git a/packaging/env.sh b/packaging/env.sh index d227a71ab..702318cde 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -22,7 +22,7 @@ workspace_version() { } VERSION="${FILMCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +32,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${FILMCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${FILMCRAFT_BUILD_SHA:-}" ]]; then FILMCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export FILMCRAFT_BUILD_SHA @@ -41,7 +41,7 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi } # Copy licence and readme files that exist into a package directory, plus the licences of the @@ -49,7 +49,7 @@ warn() { copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" } @@ -58,9 +58,9 @@ copy_docs() { # Does nothing without CRAFT_FONTS_DIR. copy_font_licences() { local dest="$1" lic family - if [ -z "${CRAFT_FONTS_DIR:-}" ]; then return 0; fi + if [[ -z "${CRAFT_FONTS_DIR:-}" ]]; then return 0; fi for lic in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do - [ -f "$lic" ] || continue + [[ -f "$lic" ]] || continue family="$(basename "$(dirname "$lic")")" cp "$lic" "$dest/OFL-$family.txt" done diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 36835b5aa..ab24da426 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -20,7 +20,7 @@ APP_ID=ai.storyteller.filmcraft SKIP_BUILD=0 DRY_RUN=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;; @@ -36,7 +36,7 @@ case "$MACHINE" in arm64 | aarch64) ARCH=aarch64 ;; *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; esac -if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then +if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 fi @@ -46,7 +46,7 @@ echo "==> FilmCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli) fi WORK="$CARGO_TARGET_DIR/freebsd-package" @@ -56,7 +56,7 @@ mkdir -p "$WORK" BIN="$CARGO_TARGET_DIR/release" OUT_DIR="$DIST" -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. BIN="$WORK/stub-bin" OUT_DIR="$WORK" @@ -81,13 +81,13 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$FILMCRAFT_BUILD_DATE/g" \ cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/" copy_docs "$STAGE/share/doc/filmcraft" for f in NOTICE ATTRIBUTION.md; do - if [ -f "$ROOT/$f" ]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/filmcraft/"; fi + if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/filmcraft/"; fi done for f in bin/filmcraft bin/filmcraft-cli "share/applications/$APP_ID.desktop" \ "share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \ share/doc/filmcraft/LICENSE-MIT share/doc/filmcraft/LICENSE-APACHE; do - if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi + if [[ ! -e "$STAGE/$f" ]]; then echo "error: $f is missing from the package" >&2; exit 1; fi done # ---- .tar.gz ------------------------------------------------------------------------------------ @@ -96,7 +96,7 @@ tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" "$STAGE/bin/filmcraft-cli" --version -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" fi diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 15d96c156..66406efb9 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -21,7 +21,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 TARBALL="" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; @@ -38,7 +38,7 @@ case "$ARCH" in esac BASENAME="filmcraft-$VERSION-linux-$ARCH" TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" -[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/filmcraft" ] || { echo "error: $TARBALL has no bin/filmcraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/filmcraft" ]] || { echo "error: $TARBALL has no bin/filmcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=filmcraft-cli "$APP_ID" --version diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh index 033fe9b7e..bd38730d5 100755 --- a/packaging/linux/install.sh +++ b/packaging/linux/install.sh @@ -9,10 +9,10 @@ usage() { } fail() { echo "error: $*" >&2; exit 1; } prefix="${HOME:?HOME is not set}/.local" -while [ "$#" -gt 0 ]; do +while [[ "$#" -gt 0 ]]; do case "$1" in --prefix) - [ "$#" -ge 2 ] || fail '--prefix needs an absolute path' + [[ "$#" -ge 2 ]] || fail '--prefix needs an absolute path' prefix="$2"; shift 2 ;; -h | --help) usage; exit 0 ;; *) usage >&2; fail "unknown argument: $1" ;; @@ -22,14 +22,14 @@ case "$prefix" in /*) ;; *) fail '--prefix must be an absolute path' ;; esac prefix="$(realpath -m -- "$prefix")" # Desktop Exec paths cannot contain '='; launchers also misinterpret '%' in executable names. [[ "$prefix" != *'='* && "$prefix" != *'%'* && ! "$prefix" =~ [[:cntrl:]] ]] || fail 'prefix cannot contain =, % or control characters' -[ "$prefix" != / ] || fail 'use /usr/local or a user directory, not /' +[[ "$prefix" != / ]] || fail 'use /usr/local or a user directory, not /' source_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" case "$prefix/" in "$source_dir/"*) fail 'choose an installation prefix outside the extracted tarball' ;; esac app_id=ai.storyteller.filmcraft for binary in filmcraft filmcraft-cli; do - [ -f "$source_dir/bin/$binary" ] || fail "missing bin/$binary; extract the complete tarball first" + [[ -f "$source_dir/bin/$binary" ]] || fail "missing bin/$binary; extract the complete tarball first" done -[ -f "$source_dir/share/applications/$app_id.desktop" ] || fail 'missing desktop entry; extract the complete tarball first' +[[ -f "$source_dir/share/applications/$app_id.desktop" ]] || fail 'missing desktop entry; extract the complete tarball first' mkdir -p -- "$prefix/bin" "$prefix/share" install -m755 -- "$source_dir/bin/filmcraft" "$prefix/bin/filmcraft" @@ -41,7 +41,7 @@ cp -R -- "$source_dir/share/." "$prefix/share/" # https://specifications.freedesktop.org/desktop-entry/latest/exec-variables.html exec_path="$(printf '%s' "$prefix/bin/filmcraft" | sed 's/[\\"`$]/\\&/g; s/\\/\\\\/g')" try_path="$(printf '%s' "$prefix/bin/filmcraft" | sed 's/\\/\\\\/g')" -while IFS= read -r line || [ -n "$line" ]; do +while IFS= read -r line || [[ -n "$line" ]]; do case "$line" in Exec=*) printf 'Exec="%s" %%F\n' "$exec_path" ;; TryExec=*) printf 'TryExec=%s\n' "$try_path" ;; diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 5001bf716..47f743d4f 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -25,7 +25,7 @@ APP_ID=ai.storyteller.filmcraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -46,8 +46,8 @@ BASENAME="filmcraft-$VERSION-linux-$ARCH" echo "==> FilmCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli) @@ -104,6 +104,20 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then + APPIMAGETOOL_VERSION=1.9.1 + # appimagetool otherwise embeds type2-runtime's unchecked `continuous` build in every AppImage. + RUNTIME_VERSION=20251108 + case "$ARCH" in + x86_64) + APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + ;; + aarch64) + APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 + ;; + *) APPIMAGETOOL_SHA256='' RUNTIME_SHA256='' ;; + esac APPDIR="$WORK/FilmCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" @@ -112,19 +126,43 @@ if has appimage; then cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" ln -s "$APP_ID.png" "$APPDIR/.DirIcon" + if [[ -z "$RUNTIME_SHA256" ]]; then + echo "no pinned AppImage runtime for $ARCH" >&2 + exit 2 + fi + RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$RUNTIME_VERSION-$ARCH" + if [[ ! -f "$RUNTIME" ]]; then + download="$RUNTIME.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$ARCH" + printf '%s %s\n' "$RUNTIME_SHA256" "$download" | sha256sum -c - + mv "$download" "$RUNTIME" + fi + printf '%s %s\n' "$RUNTIME_SHA256" "$RUNTIME" | sha256sum -c - + TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" + if [[ -z "$TOOL" ]]; then + if [[ -z "$APPIMAGETOOL_SHA256" ]]; then + echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2 + exit 2 fi + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" + if [[ ! -x "$TOOL" ]]; then + download="$TOOL.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - + chmod 755 "$download" + mv "$download" "$TOOL" + fi + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - fi # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative, e.g. target/agent-). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" APPDIR="$(cd "$APPDIR" && pwd)" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")" # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. rm -f "$OUT.zsync" # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and @@ -136,18 +174,18 @@ if has appimage; then # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/filmcraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/filmcraft-cli" --version fi echo "==> done" diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 2a11b5309..2acf4bf3d 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 85902b91b..c84028b59 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/filmcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/filmcraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> FilmCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli "${args[@]}") @@ -74,9 +74,9 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } @@ -88,8 +88,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -117,7 +117,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/FilmCraft-notarize.zip" notarize "$WORK/FilmCraft-notarize.zip" xcrun stapler staple "$APP" @@ -144,7 +144,7 @@ hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" xcrun stapler staple "$DMG" xcrun stapler validate "$DMG" @@ -162,7 +162,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/filmcraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/filmcraft-cli" --version echo "==> done" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index fa208cad6..0aa031325 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -11,10 +11,10 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then # CI (the shared release workflow installs only trunk): install the wasm-bindgen CLI at the # crate's exact version. Locally, `cargo xtask web` says which version to install. - if [ -n "${CI:-}" ]; then + if [[ -n "${CI:-}" ]]; then WASM_BINDGEN_VERSION="$(sed -n 's/^const WASM_BINDGEN: &str = "\(.*\)";$/\1/p' "$ROOT/xtask/src/main.rs")" if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -qF "$WASM_BINDGEN_VERSION"; }; then cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked @@ -24,7 +24,7 @@ if [ "${1:-}" != "--skip-build" ]; then fi SITE="$CARGO_TARGET_DIR/web/dist" -[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix. if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index 07aa8bef5..b083fc42f 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) { $script:SignTool = Find-SignTool $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'FilmCraft', '/du', 'https://github.com/storytold/filmcraft') -$tmp = Join-Path ([IO.Path]::GetTempPath()) "filmcraft-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) "filmcraft-sign-$([IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($haveCert) { @@ -73,8 +73,15 @@ try { Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. + $trustedSigningVersion = '1.0.95' + $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f' $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg" + Invoke-WebRequest -Uri $uri -OutFile $pkg + $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $trustedSigningSha256) { + throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..bd9f6a3f9 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +sonar.projectKey=storytold_filmcraft +sonar.organization=storytold +sonar.coverage.exclusions=**/tools/**,**/docs/**,**/examples/** diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 819326fcf..e3f883e0f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -16,7 +16,10 @@ //! lists, against a reference list (default: the local `plan/premiere/.json`), presence and //! placement reported separately (`parity.rs`, docs/gaps.md G7). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). -//! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. +//! - `i18n-report`: print the interface strings each language's catalog lacks (they show in +//! English). It never fails. +//! - `ci`: fmt check, clippy -D warnings, tests (`cargo nextest run` when installed) and doctests, +//! the i18n report, layers, assets, wasm. mod ico; mod parity; @@ -259,7 +262,7 @@ const WEB_CRATES: &[&str] = &["filmcraft-ui-egui", "filmcraft-web"]; fn wasm() -> Result<(), String> { let md = metadata()?; let mut cmd = Command::new(env!("CARGO")); - cmd.args(["check", "--target", "wasm32-unknown-unknown"]); + cmd.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); let mut n = 0; for (name, _) in workspace_crates(&md) { if layer_of(&name).is_some_and(|l| l <= 4) || WEB_CRATES.contains(&name.as_str()) { @@ -400,26 +403,26 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } -/// (crate, test target) whose ignored `generate_fixtures` test builds that crate's fixture matrix. -const FIXTURE_GENERATORS: &[(&str, &str)] = &[ - ("h264", "conformance"), - ("hevc", "conformance"), - ("isobmff", "oracle_demux"), - ("matroska", "oracle"), - ("prores", "oracle_decode"), - ("dnx", "oracle_decode"), - ("codecs", "mxf_oracle"), - ("codecs", "mpeg_oracle"), - ("mpeg2v", "oracle"), - ("ac3", "oracle"), - ("mpa", "oracle"), - ("codecs", "ogg_oracle"), +/// (crate, test target, test) whose ignored test builds that crate's fixture matrix. +const FIXTURE_GENERATORS: &[(&str, &str, &str)] = &[ + ("h264", "it", "conformance::generate_fixtures"), + ("hevc", "it", "conformance::generate_fixtures"), + ("isobmff", "it", "oracle_demux::generate_fixtures"), + ("matroska", "oracle", "generate_fixtures"), + ("prores", "it", "oracle_decode::generate_fixtures"), + ("dnx", "it", "oracle_decode::generate_fixtures"), + ("codecs", "it", "mxf_oracle::generate_fixtures"), + ("codecs", "it", "mpeg_oracle::generate_fixtures"), + ("mpeg2v", "it", "oracle::generate_fixtures"), + ("ac3", "oracle", "generate_fixtures"), + ("mpa", "oracle", "generate_fixtures"), + ("codecs", "it", "ogg_oracle::generate_fixtures"), ]; fn fixtures(only: &[String]) -> Result<(), String> { - let unknown: Vec<&String> = only.iter().filter(|o| !FIXTURE_GENERATORS.iter().any(|(c, _)| *c == short(o))).collect(); + let unknown: Vec<&String> = only.iter().filter(|o| !FIXTURE_GENERATORS.iter().any(|(c, _, _)| *c == short(o))).collect(); if !unknown.is_empty() { - let known: Vec<&str> = FIXTURE_GENERATORS.iter().map(|(c, _)| *c).collect(); + let known: Vec<&str> = FIXTURE_GENERATORS.iter().map(|(c, _, _)| *c).collect(); return Err(format!("no fixture generator for {unknown:?} (known: {})", known.join(", "))); } let Some(ff) = filmcraft_testkit::ffmpeg() else { @@ -429,11 +432,11 @@ fn fixtures(only: &[String]) -> Result<(), String> { println!("fixtures: {}", filmcraft_testkit::fixtures::fixtures_root().display()); let (mut made, mut cached, mut skipped) = (0, 0, 0); let mut failed = Vec::new(); - for (krate, target) in FIXTURE_GENERATORS.iter().filter(|(c, _)| only.is_empty() || only.iter().any(|o| short(o) == *c)) { + for (krate, target, test) in FIXTURE_GENERATORS.iter().filter(|(c, _, _)| only.is_empty() || only.iter().any(|o| short(o) == *c)) { let pkg = format!("filmcraft-{krate}"); - println!("\n== {pkg} ({target})"); + println!("\n== {pkg} ({test})"); let mut cmd = Command::new(env!("CARGO")); - cmd.args(["test", "--release", "-p", &pkg, "--test", target, "--", "--ignored", "--exact", "generate_fixtures", "--nocapture"]); + cmd.args(["test", "--release", "-p", &pkg, "--test", target, "--", "--ignored", "--exact", test, "--nocapture"]); cmd.stderr(std::process::Stdio::inherit()); let out = cmd.output().map_err(|e| e.to_string())?; for line in String::from_utf8_lossy(&out.stdout).lines() { @@ -456,13 +459,40 @@ fn fixtures(only: &[String]) -> Result<(), String> { fn ci() -> Result<(), String> { let cargo = env!("CARGO"); run(Command::new(cargo).args(["fmt", "--check"]))?; - run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?; - run(Command::new(cargo).args(["test", "--workspace", "--release"]))?; + run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))?; + let tested = tests(); + i18n_report(); + tested?; layers()?; assets()?; wasm() } +/// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests, +/// which nextest doesn't run. +fn tests() -> Result<(), String> { + let cargo = env!("CARGO"); + let targets = ["--workspace", "--lib", "--bins", "--tests", "--no-fail-fast"]; + let nextest = Command::new(cargo).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success()); + if nextest { + run(Command::new(cargo).args(["nextest", "run"]).args(targets))?; + } else { + println!("cargo-nextest not found, running cargo test (install: cargo install cargo-nextest --locked)"); + run(Command::new(cargo).arg("test").args(targets))?; + } + run(Command::new(cargo).args(["test", "--workspace", "--doc"])) +} + +/// The missing-translations report (`i18n::tests::missing_translations_report` in +/// `filmcraft-ui-egui`), printed to the log. A missing translation never fails a build, so +/// neither does this. +fn i18n_report() { + let args = ["test", "-p", "filmcraft-ui-egui", "--lib", "--", "--exact", "i18n::tests::missing_translations_report", "--nocapture"]; + if let Err(e) = run(Command::new(env!("CARGO")).args(args)) { + eprintln!("i18n report not printed: {e}"); + } +} + /// The headless playback benchmark (`crates/ui-egui/examples/bench_playback.rs`); extra /// arguments go to the benchmark (`cargo xtask bench-playback --scenario stack3 --res full`). fn bench_playback() -> Result<(), String> { @@ -491,6 +521,10 @@ fn main() -> ExitCode { "assets" => assets(), "fixtures" => fixtures(&std::env::args().skip(2).collect::>()), "ci" => ci(), + "i18n-report" => { + i18n_report(); + Ok(()) + } "bench" => bench(), "bench-playback" => bench_playback(), "web" => web(&std::env::args().skip(2).collect::>()),