diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..f1331de2b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, layers, assets, +# wasm) on every pull request and every push to main. Today only path-filtered workflows run on +# pull requests, so most PRs land with no test run at all. +# +# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets. +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Same craft-fonts commit as release.yml, so the font tests see what a release build embeds. + CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695 + +jobs: + gate: + name: cargo xtask ci + runs-on: ubuntu-24.04 + timeout-minutes: 120 + env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + RUST_BACKTRACE: "1" + # `xtask ci` builds clippy and the tests with --release. The release profile (thin LTO, one + # codegen unit) is for shipped binaries: on a 4-core runner it makes linking the ~220 test + # binaries take over an hour. CI keeps the optimisation level but builds in parallel units. + CARGO_PROFILE_RELEASE_LTO: "false" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check out craft-fonts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + path: craft-fonts + persist-credentials: false + + # libasound2-dev is for cpal (audio playback). + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \ + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers \ + libasound2-dev + + - name: Install Rust (stable + clippy, rustfmt, wasm32) + run: | + rustup toolchain install stable --profile minimal --component clippy,rustfmt \ + --target wasm32-unknown-unknown + rustup default stable + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Quality gate + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + run: cargo xtask ci diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a..01cb9cd66 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..ecee45a6d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/Cargo.lock b/Cargo.lock index 2dd0090a9..cd3e6a624 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1679,7 +1679,7 @@ dependencies = [ [[package]] name = "filmcraft" -version = "0.10.0" +version = "0.12.0" dependencies = [ "arboard", "chrono", @@ -1704,6 +1704,7 @@ dependencies = [ "pollster 0.4.0", "rfd", "serde_json", + "subtle", "sys-locale", "winit", "winresource", @@ -1712,7 +1713,7 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1722,7 +1723,7 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -1730,7 +1731,7 @@ dependencies = [ [[package]] name = "filmcraft-apv" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1740,14 +1741,14 @@ dependencies = [ [[package]] name = "filmcraft-audio-dsp" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-automation" -version = "0.10.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "filmcraft-engine", @@ -1763,7 +1764,7 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1774,21 +1775,21 @@ dependencies = [ [[package]] name = "filmcraft-avi" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-bitstream" -version = "0.10.0" +version = "0.12.0" dependencies = [ "proptest", ] [[package]] name = "filmcraft-captions" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-color", "filmcraft-project", @@ -1801,11 +1802,11 @@ dependencies = [ [[package]] name = "filmcraft-cfb" -version = "0.10.0" +version = "0.12.0" [[package]] name = "filmcraft-cli" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-automation", "filmcraft-codecs", @@ -1827,7 +1828,7 @@ dependencies = [ [[package]] name = "filmcraft-codecs" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-aac", "filmcraft-ac3", @@ -1865,7 +1866,7 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.10.0" +version = "0.12.0" dependencies = [ "proptest", "serde", @@ -1873,7 +1874,7 @@ dependencies = [ [[package]] name = "filmcraft-dng" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-dng", "rayon", @@ -1882,7 +1883,7 @@ dependencies = [ [[package]] name = "filmcraft-dnx" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1892,7 +1893,7 @@ dependencies = [ [[package]] name = "filmcraft-edit" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-geom", "filmcraft-media", @@ -1905,7 +1906,7 @@ dependencies = [ [[package]] name = "filmcraft-engine" -version = "0.10.0" +version = "0.12.0" dependencies = [ "craft-docpath", "filmcraft-audio-dsp", @@ -1940,7 +1941,7 @@ dependencies = [ [[package]] name = "filmcraft-export" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-aac", "filmcraft-apv", @@ -1971,7 +1972,7 @@ dependencies = [ [[package]] name = "filmcraft-flac" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1980,7 +1981,7 @@ dependencies = [ [[package]] name = "filmcraft-format" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-media", "filmcraft-project", @@ -1988,12 +1989,13 @@ dependencies = [ "log", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", ] [[package]] name = "filmcraft-frame" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2003,7 +2005,7 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.10.0" +version = "0.12.0" dependencies = [ "proptest", "serde", @@ -2011,7 +2013,7 @@ dependencies = [ [[package]] name = "filmcraft-golden" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2028,7 +2030,7 @@ dependencies = [ [[package]] name = "filmcraft-gpu" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-color", "filmcraft-export", @@ -2047,7 +2049,7 @@ dependencies = [ [[package]] name = "filmcraft-h264" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2057,7 +2059,7 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2067,7 +2069,7 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2077,7 +2079,7 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2094,7 +2096,7 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2105,7 +2107,7 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", "serde_json", @@ -2113,7 +2115,7 @@ dependencies = [ [[package]] name = "filmcraft-media" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-color", "filmcraft-dng", @@ -2129,7 +2131,7 @@ dependencies = [ [[package]] name = "filmcraft-mpa" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2138,7 +2140,7 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", "rayon", @@ -2147,7 +2149,7 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -2155,28 +2157,28 @@ dependencies = [ [[package]] name = "filmcraft-mxf" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-ogg" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-opus" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-platform" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-codecs", @@ -2202,7 +2204,7 @@ dependencies = [ [[package]] name = "filmcraft-project" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2216,7 +2218,7 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2226,7 +2228,7 @@ dependencies = [ [[package]] name = "filmcraft-render" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -2246,7 +2248,7 @@ dependencies = [ [[package]] name = "filmcraft-scopes" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-color", "serde", @@ -2255,7 +2257,7 @@ dependencies = [ [[package]] name = "filmcraft-speech" -version = "0.10.0" +version = "0.12.0" dependencies = [ "candle-core", "candle-nn", @@ -2273,14 +2275,14 @@ dependencies = [ [[package]] name = "filmcraft-testkit" -version = "0.10.0" +version = "0.12.0" dependencies = [ "png", ] [[package]] name = "filmcraft-text" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-color", "harfrust", @@ -2292,7 +2294,7 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.10.0" +version = "0.12.0" dependencies = [ "proptest", "serde", @@ -2300,7 +2302,7 @@ dependencies = [ [[package]] name = "filmcraft-tts" -version = "0.10.0" +version = "0.12.0" dependencies = [ "candle-core", "candle-nn", @@ -2314,7 +2316,7 @@ dependencies = [ [[package]] name = "filmcraft-tts-text" -version = "0.10.0" +version = "0.12.0" dependencies = [ "serde", "thiserror 2.0.21", @@ -2322,7 +2324,7 @@ dependencies = [ [[package]] name = "filmcraft-ui-egui" -version = "0.10.0" +version = "0.12.0" dependencies = [ "cpu-time", "craft-ui", @@ -2361,23 +2363,25 @@ dependencies = [ [[package]] name = "filmcraft-vp8" -version = "0.10.0" +version = "0.12.0" dependencies = [ + "filmcraft-testkit", "thiserror 2.0.21", ] [[package]] name = "filmcraft-vp9" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-bitstream", + "filmcraft-testkit", "rayon", "thiserror 2.0.21", ] [[package]] name = "filmcraft-web" -version = "0.10.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -7402,7 +7406,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.10.0" +version = "0.12.0" dependencies = [ "filmcraft-testkit", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 46716a680..00aabadd3 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" 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..8b508e334 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) @@ -169,9 +171,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()), } diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/cli.rs index d5ded27fa..57db7ae47 100644 --- a/apps/filmcraft-cli/tests/cli.rs +++ b/apps/filmcraft-cli/tests/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-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/Cargo.toml b/apps/filmcraft/Cargo.toml index 3ae35e6c6..6962a2330 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -29,6 +29,8 @@ filmcraft-platform = { workspace = true } egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } serde_json = { workspace = true } +# Constant-time comparison of control-channel credentials. +subtle = "2.6" log = { workspace = true } rfd = { workspace = true } cpal = "0.16" 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/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 f08fe0b8d..7ed4c4936 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 { @@ -147,6 +159,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 { @@ -164,3 +177,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/apv/tests/fuzz_hostile.rs b/crates/apv/tests/fuzz_hostile.rs new file mode 100644 index 000000000..bb02fdd07 --- /dev/null +++ b/crates/apv/tests/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/automation/Cargo.toml b/crates/automation/Cargo.toml index 14b090e7d..3a2e4b965 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -16,7 +16,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time", "fs"] } base64 = "0.22" schemars = "1" image = { version = "0.25", default-features = false, features = ["png"] } 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..3a9cb8e1c 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -568,11 +568,11 @@ impl FilmcraftMcp { if let Err(e) = b.call("ui.screenshot", json!({"path": path.to_string_lossy(), "panel": panel})).await { return Ok(fail(e)); } - let bytes = match std::fs::read(&path) { + let bytes = match tokio::fs::read(&path).await { Ok(b) => b, Err(e) => return Ok(fail(format!("screenshot: {e}"))), }; - let _ = std::fs::remove_file(&path); + let _ = tokio::fs::remove_file(&path).await; let bytes = match image::load_from_memory(&bytes) { Ok(img) => { let rgba = img.to_rgba8(); diff --git a/crates/av1/README.md b/crates/av1/README.md index 354484158..3045e9d37 100644 --- a/crates/av1/README.md +++ b/crates/av1/README.md @@ -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..eae16133b 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; @@ -505,7 +516,7 @@ impl Decoder { } // Encoders exist that code frames slightly larger than the sequence maximum (e.g. a // 270-line stream coded as 272 lines); like other decoders we accept them. - if fh.upscaled_width > 65536 || fh.frame_height > 65536 { + if !frame_size_allowed(fh.upscaled_width, fh.frame_height) { return Err(Error::Invalid("frame size")); } if !fh.frame_is_intra { @@ -947,3 +958,26 @@ fn project(fs: &mut FrameState, src: usize, dst_sign: i32) -> bool { } true } + +#[cfg(test)] +mod tests { + use super::frame_size_allowed; + + #[test] + fn frames_up_to_the_level_6_3_limits_are_allowed() { + assert!(frame_size_allowed(16384, 2176)); // exactly MaxPicSize + assert!(frame_size_allowed(4096, 8704)); // exactly MaxVSize + assert!(frame_size_allowed(8192, 4352)); // exactly MaxPicSize + assert!(frame_size_allowed(7680, 4320)); // 8K UHD + assert!(frame_size_allowed(1, 1)); + } + + #[test] + fn frames_beyond_the_limits_are_refused() { + assert!(!frame_size_allowed(16384, 2177)); // one row over MaxPicSize + assert!(!frame_size_allowed(16384, 8704)); // within both sides, four times MaxPicSize + assert!(!frame_size_allowed(4096, 8705)); // one row over MaxVSize + assert!(!frame_size_allowed(16385, 1)); // one column over MaxHSize + assert!(!frame_size_allowed(65536, 65536)); + } +} 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/hostile_dims.rs b/crates/av1/tests/hostile_dims.rs new file mode 100644 index 000000000..1312e938d --- /dev/null +++ b/crates/av1/tests/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/tools/extract_tables.py b/crates/av1/tools/extract_tables.py index 83c4e2c9f..5ee6d95cf 100644 --- a/crates/av1/tools/extract_tables.py +++ b/crates/av1/tools/extract_tables.py @@ -17,6 +17,16 @@ dimensions; the element type is the narrowest of u8/i8/u16/i16/i32 that holds ev import os import re import sys +from pathlib import Path + + +def _spec_path(arg): + """Resolve a CLI path argument and require it to be an existing regular file.""" + path = Path(arg).resolve() + if not path.is_file(): + sys.exit(f"not a readable file: {arg}") + return path + root = sys.argv[1] files = sorted(f for f in os.listdir(root) if f.endswith(".md")) 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/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/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/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/dnx/tests/fuzz_hostile.rs b/crates/dnx/tests/fuzz_hostile.rs new file mode 100644 index 000000000..b3eee0037 --- /dev/null +++ b/crates/dnx/tests/fuzz_hostile.rs @@ -0,0 +1,110 @@ +//! Hostile-input robustness: every header byte set to extreme values, every bit flipped, +//! truncation at (nearly) every length and random mutation of encoder-made frames must never +//! panic or run away (errors are fine). No ffmpeg needed. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with, probe}; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +fn seeds() -> Vec> { + let mut v = Vec::new(); + for (profile, depth, w, h) in + [(Profile::Lb, 8, 64u32, 48u32), (Profile::Hq, 8, 48, 32), (Profile::Hqx, 10, 64, 32), (Profile::Hqx, 12, 48, 32), (Profile::R444, 10, 48, 32)] + { + let mut cfg = EncoderConfig::new(profile, w, h); + cfg.bit_depth = depth; + let chroma = cfg.chroma(); + let mut f = Frame::new(w, h, chroma, depth, false); + for (i, s) in f.y.iter_mut().enumerate() { + *s = ((16 + (i * 37) % 200) as u16) << (depth - 8); + } + if chroma != ChromaFormat::Yuv444 { + f.cb.fill(128 << (depth - 8)); + f.cr.fill(100 << (depth - 8)); + } + if let Ok(mut enc) = Encoder::with_config(cfg) + && let Ok(d) = enc.encode(&f) + { + v.push(d); + } + } + v +} + +fn run(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + let res = catch_unwind(AssertUnwindSafe(|| { + let _ = probe(d); + for opts in [DecodeOptions::default(), DecodeOptions { threads: true, spec_dequant: true }] { + let _ = decode_frame_with(d, &opts); + } + })); + assert!(res.is_ok(), "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +#[test] +fn hostile_frames_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let seeds = seeds(); + assert!(!seeds.is_empty()); + for (si, seed) in seeds.iter().enumerate() { + let stride = (seed.len() / 400).max(1); + for n in (0..seed.len()).step_by(stride) { + run(&seed[..n], &format!("seed {si} truncate {n}")); + } + // The header (and the start of the scan-line index): every byte to extreme values, every bit. + for p in 0..seed.len().min(0x300) { + for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + let mut d = seed.clone(); + d[p] = v; + run(&d, &format!("seed {si} byte {p:#x}={v:#x}")); + } + for b in 0..8 { + let mut d = seed.clone(); + d[p] ^= 1 << b; + run(&d, &format!("seed {si} flip {p:#x}.{b}")); + } + } + // Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C). + for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] { + let mut d = seed.clone(); + if let Some(s) = d.get_mut(off..off + 2) { + s.copy_from_slice(&v.to_be_bytes()); + } + run(&d, &format!("seed {si} u16 {off:#x}={v:#x}")); + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64); + let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1500); + for i in 0..iters { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(8) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + run(&d, &format!("seed {si} random {i}")); + } + } +} 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/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/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/format/Cargo.toml b/crates/format/Cargo.toml index f2c1e982e..3fcb96254 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -17,5 +17,8 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = "3.27" + [dev-dependencies] filmcraft-time = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 0f1044ca6..086333cda 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -46,8 +46,37 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> { Ok(()) } -/// Best-effort directory fsync (persists the rename on POSIX; not possible on Windows, where -/// `MoveFileEx` with write-through semantics is already durable enough). +/// Publish a complete new file without replacing an occupied final path, including a symlink. +/// Bytes are staged and synced in the destination directory before no-clobber publication. +/// A write failure leaves no partial final file; temporary-file cleanup is best effort. +#[cfg(not(target_arch = "wasm32"))] +pub fn atomic_write_new(path: &Path, data: &[u8]) -> io::Result<()> { + atomic_write_new_with(path, |file| file.write_all(data).and_then(|_| file.sync_all())) +} + +#[cfg(target_arch = "wasm32")] +pub fn atomic_write_new(_path: &Path, _data: &[u8]) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "native exclusive file creation is unavailable in the browser")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn atomic_write_new_with(path: &Path, write: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + let dir = parent_dir(path); + #[cfg(unix)] + let mut staged = { + use std::os::unix::fs::PermissionsExt; + // Match ordinary file-creation permissions after the process umask. + tempfile::Builder::new().permissions(fs::Permissions::from_mode(0o666)).tempfile_in(&dir)? + }; + #[cfg(not(unix))] + let mut staged = tempfile::NamedTempFile::new_in(&dir)?; + write(staged.as_file_mut())?; + let _published = staged.persist_noclobber(path).map_err(|e| e.error)?; + sync_dir(&dir); + Ok(()) +} + +/// Best-effort directory sync; directory handles are not uniformly readable or syncable. fn sync_dir(dir: &Path) { if let Ok(d) = File::open(dir) { let _ = d.sync_all(); @@ -92,6 +121,66 @@ mod tests { fs::remove_dir_all(&d).unwrap(); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn new_file_is_published_complete_without_clobbering_or_temp_leaks() { + let d = tmpdir("new"); + let p = d.join("audio.wav"); + atomic_write_new(&p, b"complete").unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let reference = d.join("normal-create"); + OpenOptions::new().write(true).create_new(true).open(&reference).unwrap(); + assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, fs::metadata(&reference).unwrap().permissions().mode() & 0o777); + fs::remove_file(reference).unwrap(); + } + assert_eq!(atomic_write_new(&p, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + #[cfg(unix)] + { + let link = d.join("link.wav"); + std::os::unix::fs::symlink(&p, &link).unwrap(); + assert_eq!(atomic_write_new(&link, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + } + let expected = if cfg!(unix) { 2 } else { 1 }; + assert_eq!(fs::read_dir(&d).unwrap().count(), expected, "staging file remained after publication"); + fs::remove_dir_all(&d).unwrap(); + } + + #[cfg(unix)] + #[test] + fn new_file_can_publish_in_a_directory_without_read_permission() { + use std::os::unix::fs::PermissionsExt; + let d = tmpdir("write-only-directory"); + let original_permissions = fs::metadata(&d).unwrap().permissions(); + fs::set_permissions(&d, fs::Permissions::from_mode(0o300)).unwrap(); + let p = d.join("audio.wav"); + let result = atomic_write_new(&p, b"complete"); + fs::set_permissions(&d, original_permissions).unwrap(); + assert!(result.is_ok(), "{result:?}"); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + fs::remove_dir_all(&d).unwrap(); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn failed_staging_leaves_no_partial_destination() { + let d = tmpdir("failed-new"); + let p = d.join("audio.wav"); + let error = atomic_write_new_with(&p, |file| { + file.write_all(b"partial")?; + Err(io::Error::other("simulated full disk")) + }) + .unwrap_err(); + assert_eq!(error.to_string(), "simulated full disk"); + assert!(!p.exists()); + assert_eq!(fs::read_dir(&d).unwrap().count(), 0); + fs::remove_dir_all(&d).unwrap(); + } + #[test] fn missing_directory_is_an_error() { let d = tmpdir("missing"); diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 4608c1061..e026aed10 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; @@ -55,9 +56,10 @@ pub fn display_time(unix: i64, offset_secs: i32) -> String { format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}") } -/// `-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 +72,33 @@ fn is_stamp(s: &str) -> bool { }) } -/// Auto-saves of project `name` in `dir`, oldest first. +/// Parse `YYYY-MM-DD_HH-MM-SS` or `YYYY-MM-DD_HH-MM-SS-` 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. @@ -124,12 +139,40 @@ mod tests { #[test] fn names_and_stems() { - assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00.fcproj"); + assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00-0.fcproj"); assert_eq!(file_stem("a/b:c"), "a_b_c"); assert_eq!(file_stem(" "), "Untitled"); assert_eq!(auto_save_dir(Path::new("/x/y/p.fcproj")), Path::new("/x/y/Auto-Save")); } + #[test] + fn rotation_survives_the_clock_going_back_at_the_end_of_dst() { + let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + let t0 = 1_792_900_000; + // 02:30 at +02:00, then 40 minutes later the clock reads 02:10 at +01:00. + let old = write_auto_save(&d, "Cut", t0, 7200, b"old", 1).unwrap(); + let new = write_auto_save(&d, "Cut", t0 + 2400, 3600, b"new", 1).unwrap(); + assert!(new.exists(), "the newest auto-save was deleted by rotation"); + assert!(!old.exists(), "the oldest auto-save should be rotated out"); + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn repeated_wall_clock_hour_does_not_overwrite_earlier_saves() { + let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst2-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + let t0 = 1_792_900_000; + // The same wall-clock second, one hour apart (+02:00 then +01:00). + let a = write_auto_save(&d, "Cut", t0, 7200, b"first", 5).unwrap(); + let b = write_auto_save(&d, "Cut", t0 + 3600, 3600, b"second", 5).unwrap(); + assert_ne!(a, b); + assert_eq!(fs::read(&a).unwrap(), b"first"); + assert_eq!(fs::read(&b).unwrap(), b"second"); + assert_eq!(list_auto_saves(&d, "Cut"), vec![a, b]); + fs::remove_dir_all(&d).unwrap(); + } + #[test] fn rotation_keeps_newest_and_ignores_other_files() { let d = std::env::temp_dir().join(format!("filmcraft-rotate-{}", std::process::id())); @@ -142,8 +185,8 @@ mod tests { fs::write(d.join("notes.txt"), b"keep").unwrap(); let left = list_auto_saves(&d, "Cut"); assert_eq!(left.len(), 5); - assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_120, 0)))); - assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_360, 0)))); + assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}-1790000120.fcproj", timestamp(1_790_000_120, 0)))); + assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}-1790000360.fcproj", timestamp(1_790_000_360, 0)))); assert_eq!(list_auto_saves(&d, "Cut-Two").len(), 1); assert!(d.join("notes.txt").exists()); rotate(&d, "Cut", 2).unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 70af3c747..c88f9ef15 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"; diff --git a/crates/h264/tests/fuzz_hostile.rs b/crates/h264/tests/fuzz_hostile.rs new file mode 100644 index 000000000..d45af4148 --- /dev/null +++ b/crates/h264/tests/fuzz_hostile.rs @@ -0,0 +1,136 @@ +//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and +//! random mutation of small libx264 streams must never panic or run away. Skips without ffmpeg. + +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; + +use filmcraft_h264::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, extra x264 args, pix_fmt) +const SEEDS: &[(&str, &str, &str)] = &[ + ("h264_fz_cabac_b", "-profile:v high -bf 2 -g 8 -x264-params cabac=1:weightp=2:8x8dct=1", "yuv420p"), + ("h264_fz_cavlc", "-profile:v baseline -g 6 -x264-params cabac=0", "yuv420p"), + ("h264_fz_interlaced", "-profile:v high -flags +ildct+ilme -bf 1 -g 6 -x264-params tff=1:interlaced=1", "yuv420p"), + ("h264_fz_10bit_422", "-profile:v high422 -bf 1 -g 6", "yuv422p10le"), + ("h264_fz_multislice", "-profile:v main -g 6 -x264-params slices=3:cabac=1", "yuv420p"), +]; + +fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option> { + let out = filmcraft_testkit::fixtures_dir("h264").join(format!("{name}.h264")); + let p = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let a: Vec<&str> = args.split_whitespace().collect(); + matches!( + Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", "libx264"]) + .args(a) + .args(["-f", "h264"]) + .arg(tmp) + .stdin(Stdio::null()) + .status(), + Ok(s) if s.success() + ) + })?; + std::fs::read(p).ok() +} + +static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + for threads in [1, 3] { + let mut dec = Decoder::with_threads(threads); + for au in common::split_access_units(d) { + let _ = dec.decode(au, 0); + } + let _ = dec.decode(d, 1); + let _ = dec.flush(); + let _ = dec.take_error(); + } + })); + assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("h264 fuzz") else { return }; + for &(name, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libx264") { + filmcraft_testkit::skip("h264 fuzz", &format!("this ffmpeg has no libx264 encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libx264 but could not encode the {name} seed"); + }; + let stride = (seed.len() / 300).max(1); + for n in (0..seed.len()).step_by(stride) { + run_all(&seed[..n], &format!("{name} truncate {n}")); + } + // SPS/PPS/first slice header region: every byte to a few values, every bit + for p in 0..seed.len().min(120) { + for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + let mut d = seed.clone(); + d[p] = v; + run_all(&d, &format!("{name} byte {p}={v:#x}")); + } + for b in 0..8 { + let mut d = seed.clone(); + d[p] ^= 1 << b; + run_all(&d, &format!("{name} flip {p}.{b}")); + } + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); + for i in 0..iters() { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(6) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + run_all(&d, &format!("{name} random {i}")); + } + } +} + +/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt). +fn iters() -> usize { + std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(400) +} diff --git a/crates/hevc/tests/fuzz_hostile.rs b/crates/hevc/tests/fuzz_hostile.rs new file mode 100644 index 000000000..ac6829315 --- /dev/null +++ b/crates/hevc/tests/fuzz_hostile.rs @@ -0,0 +1,137 @@ +//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and +//! random mutation of small libx265 streams must never panic or run away. Skips without ffmpeg. + +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; + +use filmcraft_hevc::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, encoder, extra encoder args, pix_fmt) +const SEEDS: &[(&str, &str, &str, &str)] = &[ + ("hevc_fz_main_b", "libx265", "-x265-params log-level=error:bframes=2:keyint=8:min-keyint=8:sao=1:amp=1", "yuv420p"), + ("hevc_fz_main10", "libx265", "-x265-params log-level=error:bframes=1:keyint=6:min-keyint=6", "yuv420p10le"), + ("hevc_fz_wpp_slices", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6:wpp=1:slices=3", "yuv420p"), + // x265 cannot encode tiles; kvazaar can, at 256 x 64 luma per tile at least (Main profile) + ("hevc_fz_tiles_kvazaar", "libkvazaar", "-s 512x128 -kvazaar-params tiles=2x2,gop=0,period=6,qp=40", "yuv420p"), + ("hevc_fz_422_10", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6", "yuv422p10le"), +]; + +fn make(ff: &std::path::Path, name: &str, enc: &str, args: &str, pix: &str) -> Option> { + let out = filmcraft_testkit::fixtures_dir("hevc").join(format!("{name}.hevc")); + let p = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let a: Vec<&str> = args.split_whitespace().collect(); + matches!( + Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", enc]) + .args(a) + .args(["-f", "hevc"]) + .arg(tmp) + .stdin(Stdio::null()) + .status(), + Ok(s) if s.success() + ) + })?; + std::fs::read(p).ok() +} + +static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + for threads in [1, 3] { + let mut dec = Decoder::with_threads(threads); + for au in common::split_access_units(d) { + let _ = dec.decode(au, 0); + } + let _ = dec.decode(d, 1); + let _ = dec.flush(); + let _ = dec.take_error(); + } + })); + assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("hevc fuzz") else { return }; + for &(name, enc, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, enc) { + filmcraft_testkit::skip("hevc fuzz", &format!("this ffmpeg has no {enc} encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, enc, args, pix) else { + panic!("ffmpeg has {enc} but could not encode the {name} seed"); + }; + let stride = (seed.len() / 300).max(1); + for n in (0..seed.len()).step_by(stride) { + run_all(&seed[..n], &format!("{name} truncate {n}")); + } + // SPS/PPS/first slice header region: every byte to a few values, every bit + for p in 0..seed.len().min(120) { + for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + let mut d = seed.clone(); + d[p] = v; + run_all(&d, &format!("{name} byte {p}={v:#x}")); + } + for b in 0..8 { + let mut d = seed.clone(); + d[p] ^= 1 << b; + run_all(&d, &format!("{name} flip {p}.{b}")); + } + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); + for i in 0..iters() { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(6) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + run_all(&d, &format!("{name} random {i}")); + } + } +} + +/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt). +fn iters() -> usize { + std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(400) +} diff --git a/crates/hevc/tools/extract_tables.py b/crates/hevc/tools/extract_tables.py index 8b9fa3979..6ee5759df 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") diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs index 2823ff755..9d4ed6b05 100644 --- a/crates/interchange/src/common.rs +++ b/crates/interchange/src/common.rs @@ -65,7 +65,9 @@ pub fn file_url_to_path(url: &str) -> String { return url.to_string(); } let rest = &url[7..]; - let rest = if rest.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest }; + let is_localhost = + rest.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("localhost")) && rest.get(9..).is_some_and(|after| after.is_empty() || after.starts_with('/')); + let rest = if is_localhost { &rest[9..] } else { rest }; let decoded = percent_decode(rest); // file:///C:/x -> C:/x if decoded.len() >= 3 && decoded.starts_with('/') && is_windows_abs(&decoded[1..]) { @@ -692,6 +694,14 @@ mod tests { } } + #[test] + fn file_url_localhost_prefix_host_is_kept() { + assert_eq!(file_url_to_path("file://localhost-nas/share/a.mov"), "//localhost-nas/share/a.mov"); + assert_eq!(file_url_to_path("file://LOCALHOSTX/a"), "//LOCALHOSTX/a"); + assert_eq!(file_url_to_path("file://localhost/a/b"), "/a/b"); + assert_eq!(file_url_to_path("file://LocalHost/a"), "/a"); + } + #[test] fn paths() { assert_eq!(resolve_path("media/a.mov", Some("/proj")), "/proj/media/a.mov"); diff --git a/crates/mpeg2v/README.md b/crates/mpeg2v/README.md index 0562ffed3..90d6d758d 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 diff --git a/crates/mpeg2v/src/lib.rs b/crates/mpeg2v/src/lib.rs index 261742c25..6d8cb0497 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); diff --git a/crates/mpeg2v/tests/fuzz_hostile.rs b/crates/mpeg2v/tests/fuzz_hostile.rs new file mode 100644 index 000000000..d293af2d2 --- /dev/null +++ b/crates/mpeg2v/tests/fuzz_hostile.rs @@ -0,0 +1,142 @@ +//! Hostile-input robustness: truncation at every length, byte/bit mutation and header-field +//! sweeps over a small ffmpeg-generated stream must never panic or run away (errors are fine). +//! Skips when ffmpeg is missing. + +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use filmcraft_mpeg2v::{Decoder, access_units, probe, scan_access_unit}; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +const SPECS: [common::Spec; 3] = [ + common::Spec { + name: "m2v_fuzz_seed", + lavfi: "testsrc2=size=64x48:rate=25", + frames: 8, + enc: &["-c:v", "mpeg2video", "-bf", "2", "-g", "6", "-b:v", "500k"], + pix_fmt: "yuv420p", + }, + common::Spec { + name: "m2v_fuzz_seed_interlaced", + lavfi: "testsrc2=size=96x64:rate=25,tinterlace=mode=interleave_top,setfield=tff", + frames: 6, + enc: &["-c:v", "mpeg2video", "-flags", "+ilme+ildct", "-bf", "1", "-g", "6", "-b:v", "800k"], + pix_fmt: "yuv420p", + }, + common::Spec { + name: "m2v_fuzz_seed_422", + lavfi: "testsrc2=size=64x48:rate=25", + frames: 4, + enc: &["-c:v", "mpeg2video", "-pix_fmt", "yuv422p", "-profile:v", "0", "-b:v", "1M"], + pix_fmt: "yuv422p", + }, +]; + +static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + let _ = probe(d); + let _ = scan_access_unit(d); + for threads in [false, true] { + let mut dec = Decoder::new(); + dec.set_threads(threads); + for (i, r) in access_units(d).into_iter().enumerate() { + let _ = dec.decode(&d[r], i as i64); + } + // also the whole buffer as one unit + let _ = dec.decode(d, 99); + let _ = dec.flush(); + } + })); + assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpeg2v fuzz") else { return }; + for spec in &SPECS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "mpeg2video") { + filmcraft_testkit::skip("mpeg2v fuzz", &format!("this ffmpeg has no mpeg2video encoder (seed {})", spec.name)); + continue; + } + let Some(path) = common::make(&ff, spec) else { + panic!("ffmpeg has mpeg2video but could not encode the {} seed", spec.name); + }; + fuzz(spec.name, &std::fs::read(path).unwrap()); + } +} + +fn fuzz(name: &str, seed: &[u8]) { + // every truncation length (stride keeps it quick on larger seeds) + let stride = (seed.len() / 600).max(1); + for n in (0..seed.len()).step_by(stride) { + run_all(&seed[..n], &format!("{name} truncate {n}")); + } + // header region: every byte to 0 / 0xff / 0x80 / each single bit + for p in 0..seed.len().min(96) { + for v in [0u8, 0xff, 0x80, 0x01] { + let mut d = seed.to_vec(); + d[p] = v; + run_all(&d, &format!("{name} byte {p}={v:#x}")); + } + for b in 0..8 { + let mut d = seed.to_vec(); + d[p] ^= 1 << b; + run_all(&d, &format!("{name} flip {p}.{b}")); + } + } + // random multi-mutations + let mut rng = Rng(0x9e3779b97f4a7c15); + for i in 0..iters() { + let mut d = seed.to_vec(); + for _ in 0..1 + rng.below(6) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + run_all(&d, &format!("{name} random {i}")); + } +} + +/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt). +fn iters() -> usize { + std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(2500) +} diff --git a/crates/mpeg2v/tests/hostile_dims.rs b/crates/mpeg2v/tests/hostile_dims.rs new file mode 100644 index 000000000..01dcf25f8 --- /dev/null +++ b/crates/mpeg2v/tests/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/prores/tests/fuzz_hostile.rs b/crates/prores/tests/fuzz_hostile.rs new file mode 100644 index 000000000..04a9cb35a --- /dev/null +++ b/crates/prores/tests/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/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/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/tts-text/src/normalize.rs b/crates/tts-text/src/normalize.rs index 1378b7269..46f74075c 100644 --- a/crates/tts-text/src/normalize.rs +++ b/crates/tts-text/src/normalize.rs @@ -95,6 +95,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; @@ -413,6 +431,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; @@ -944,7 +964,7 @@ fn parse_wordish(rest: &str, _lang: Lang) -> Option<(Vec, usize)> { if word.len() == 1 && matches!(lower.as_str(), "a" | "p") && peek(rest, used) == Some('.') && matches!(peek(rest, used + 1), Some('m') | Some('M')) { let after_m = used + 2; if peek(rest, after_m).is_none_or(|c| !c.is_alphanumeric()) { - let mut tokens = vec![Token::Word(word.to_ascii_lowercase())]; + let mut tokens = vec![Token::Word(if lower == "a" { "A".into() } else { lower.clone() })]; tokens.push(Token::Word("m".into())); let consumed = if peek(rest, after_m) == Some('.') { after_m + 1 } else { after_m }; return Some((tokens, consumed)); @@ -1237,6 +1257,24 @@ mod tests { assert_eq!(words("$0.50", Lang::EnUs), "zero dollars and fifty cents"); } + #[test] + fn negative_money_keeps_its_sign() { + for lang in [Lang::EnUs, Lang::EnGb] { + assert_eq!(words("-$5", lang), "minus five dollars"); + assert_eq!(words("-€3.50", lang), "minus three euros and fifty cents"); + assert_eq!(words("-£2", lang), "minus two pounds"); + assert_eq!(words("a refund of -$1.05", lang), "a refund of minus one dollar and five cents"); + assert_eq!(words("(-$5)", lang), "( minus five dollars )"); + // A hyphen between two amounts is a range, not a sign. + assert_eq!(words("$10-$20", lang), "ten dollars - twenty dollars"); + assert_eq!(words("€5-€8", lang), "five euros - eight euros"); + assert_eq!(words("$10.50-$20", lang), "ten dollars and fifty cents - twenty dollars"); + // A hyphen after a word or before a non-amount stays punctuation. + assert_eq!(words("a-$5", lang), "a - five dollars"); + assert!(!words("-$x", lang).contains("minus")); + } + } + #[test] fn money_fraction_beyond_two_digits_is_not_hundreds_of_cents() { for lang in [Lang::EnUs, Lang::EnGb] { @@ -1327,7 +1365,7 @@ mod tests { assert_eq!(words("23:59", Lang::EnUs), "twenty-three fifty-nine"); assert_eq!(words("3:30pm", Lang::EnUs), "three thirty p m"); assert_eq!(words("3:30 p.m.", Lang::EnUs), "three thirty p m"); - assert_eq!(words("9:00 a.m.", Lang::EnUs), "nine zero a m"); + assert_eq!(words("9:00 a.m.", Lang::EnUs), "nine zero A m"); } #[test] diff --git a/crates/tts-text/src/phonemize.rs b/crates/tts-text/src/phonemize.rs index 6d6c1f33d..e380cc5d9 100644 --- a/crates/tts-text/src/phonemize.rs +++ b/crates/tts-text/src/phonemize.rs @@ -1335,6 +1335,16 @@ PERCENT P ER0 S EH1 N T } } + #[test] + fn am_reads_the_letter_name_not_the_article() { + for text in ["At 9:00 a.m.", "At 9:00am"] { + let chunks = phonemize(text, Lang::EnUs, &test_lex(), &Overrides::default()).unwrap(); + let joined = format!("{chunks:?}"); + assert!(joined.contains("ˈA"), "{text}: {joined}"); + assert!(!joined.contains('ə'), "{text}: {joined}"); + } + } + #[test] fn every_output_char_is_in_symbols() { let inputs = [ diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 50faf93a8..1e2d09ba6 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1382,9 +1382,11 @@ mod tests { output.textures_delta.clear(); ctx.fonts_mut(|fonts| { // every theme family falls back to the craft-fonts (when built with them; no system - // font is added then) or to the system Japanese font + // font is added then) or to the system Japanese font. Installed right-to-left faces + // (`rtl::install`) come after every other fallback by design, so they are left out. for family in crate::theme::font_families() { - let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default(); + let mut stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default(); + stack.retain(|n| !crate::rtl::is_system_face(n)); if craft { assert!(stack.last().is_some_and(|n| n.starts_with("craft:")), "{family:?}: {stack:?}"); assert!(!stack.iter().any(|n| n == JAPANESE_FONT), "{family:?}: {stack:?}"); 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/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/clip_dialogs_ui.rs b/crates/ui-egui/tests/clip_dialogs_ui.rs index 72c947fb0..2d9bbf20f 100644 --- a/crates/ui-egui/tests/clip_dialogs_ui.rs +++ b/crates/ui-egui/tests/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/ui_control_input.rs b/crates/ui-egui/tests/ui_control_input.rs index 773b451dd..83b3c054e 100644 --- a/crates/ui-egui/tests/ui_control_input.rs +++ b/crates/ui-egui/tests/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/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/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/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/tests/fuzz_hostile.rs b/crates/vp9/tests/fuzz_hostile.rs new file mode 100644 index 000000000..70f884ec0 --- /dev/null +++ b/crates/vp9/tests/fuzz_hostile.rs @@ -0,0 +1,162 @@ +//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small +//! ffmpeg-generated VP9 streams (frame by frame) must never panic or run away. Skips without ffmpeg. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use filmcraft_vp9::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, encoder args, pix_fmt) +const SEEDS: &[(&str, &str, &str)] = &[ + ("vp9_fz_default", "-c:v libvpx-vp9 -g 4 -b:v 300k -lag-in-frames 0", "yuv420p"), + // libvpx needs 256 px per tile column, so two columns need 512 px + ("vp9_fz_2tiles_10bit", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -tile-columns 1 -profile:v 2 -s 512x48", "yuv420p10le"), + ("vp9_fz_444", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -profile:v 1", "yuv444p"), + ("vp9_fz_altref", "-c:v libvpx-vp9 -g 6 -b:v 300k -auto-alt-ref 1 -lag-in-frames 5", "yuv420p"), +]; + +fn ivf_frames(data: &[u8]) -> Vec> { + 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}"); +} + +/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt). +fn iters() -> usize { + std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300) +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP9 fuzz") else { return }; + for &(name, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx-vp9") { + filmcraft_testkit::skip("VP9 fuzz", &format!("this ffmpeg has no libvpx-vp9 encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libvpx-vp9 but could not encode the {name} seed"); + }; + if seed.is_empty() { + filmcraft_testkit::skip("VP9 fuzz", &format!("the {name} seed has no frames")); + continue; + } + // truncate each of the first frames at every length + for fi in 0..seed.len().min(3) { + let len = seed[fi].len(); + for n in (0..len).step_by((len / 200).max(1)) { + let mut d = seed.clone(); + d[fi].truncate(n); + run_all(&d, &format!("{name} frame {fi} truncate {n}")); + } + } + // header region of every frame: every byte to a few values, every bit + for fi in 0..seed.len() { + for p in 0..seed[fi].len().min(96) { + for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + let mut d = seed.clone(); + d[fi][p] = v; + run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}")); + } + for b in 0..8 { + let mut d = seed.clone(); + d[fi][p] ^= 1 << b; + run_all(&d, &format!("{name} frame {fi} flip {p}.{b}")); + } + } + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); + for i in 0..iters() { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(6) { + let fi = rng.below(d.len()); + if d[fi].is_empty() { + continue; + } + let p = rng.below(d[fi].len()); + if rng.below(2) == 0 { + d[fi][p] ^= 1 << rng.below(8); + } else { + d[fi][p] = rng.next() as u8; + } + } + run_all(&d, &format!("{name} random {i}")); + } + } +} diff --git a/crates/vp9/tools/extract_tables.py b/crates/vp9/tools/extract_tables.py index bdd59bb7b..d3870f530 100644 --- a/crates/vp9/tools/extract_tables.py +++ b/crates/vp9/tools/extract_tables.py @@ -12,8 +12,18 @@ element count checked against the declared dimensions. """ import re import sys +from pathlib import Path -text = open(sys.argv[1], encoding="utf-8").read().replace("−", "-").replace("–", "-") + +def _spec_path(arg): + """Resolve a CLI path argument and require it to be an existing regular file.""" + path = Path(arg).resolve() + if not path.is_file(): + sys.exit(f"not a readable file: {arg}") + return path + + +text = open(_spec_path(sys.argv[1]), encoding="utf-8").read().replace("−", "-").replace("–", "-") lines = [l for l in text.split("\n") if "Copyright" not in l and "VP9 Bitstream & Decoding Process Specification" not in l] text = "\n".join(lines) diff --git a/docs/agents.md b/docs/agents.md index 4b85410f0..b680ce99a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -23,6 +23,7 @@ and write wherever the user can. ```sh cargo build --release -p filmcraft-cli -p filmcraft +export FILMCRAFT_CONTROL_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" ./target/release/filmcraft --control 9876 & # only for the bridge server claude # approve the project MCP servers when asked ``` @@ -134,29 +135,31 @@ progress and stop it: ## 2. Control channel `filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT=9876`) listens on `127.0.0.1` only. Send one -JSON request per line and get one JSON reply per line. Full method table: +JSON request per line and get one JSON reply per line. Set a fresh `FILMCRAFT_CONTROL_TOKEN` for the +desktop session as above; the CLI / MCP bridge must inherit the same variable. Raw requests must +include it as `token` on every line (replace the placeholders below). Full setup and method table: [control-protocol.md](control-protocol.md). ```jsonc -{"id":1,"method":"engine.commands"} -{"id":2,"method":"engine.execute","params":{"command":"file.openDemoProject"}} -{"id":3,"method":"engine.execute","params":{"command":"playhead.set","params":{"seconds":2}}} -{"id":4,"method":"engine.execute","params":{"command":"sequence.addEdit"}} -{"id":5,"method":"engine.execute","params":{"command":"effects.apply","params":{"effect":"Gaussian Blur"}}} -{"id":6,"method":"ui.elements","params":{"prefix":"tools."}} -{"id":7,"method":"ui.click","params":{"id":"tools.Razor"}} -{"id":8,"method":"ui.key","params":{"key":"Cmd+Z"}} -{"id":9,"method":"ui.screenshot","params":{"path":"/tmp/timeline.png","panel":"Timeline"}} +{"id":1,"token":"","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..6b5c05108 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 | 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/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/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/packaging/linux/package.sh b/packaging/linux/package.sh index 5001bf716..f92544f93 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -116,7 +116,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi 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/**