Merge branch 'main' into fix/h1135-scc-timecode-parser-accepts-sign

This commit is contained in:
skylar taylor-barrick
2026-10-22 22:30:39 -06:00
committed by GitHub
71 changed files with 2888 additions and 178 deletions
+74
View File
@@ -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
+3 -13
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
Generated
+58 -54
View File
@@ -1679,7 +1679,7 @@ dependencies = [
[[package]]
name = "filmcraft"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"arboard",
"chrono",
@@ -1704,6 +1704,7 @@ dependencies = [
"pollster 0.4.0",
"rfd",
"serde_json",
"subtle",
"sys-locale",
"winit",
"winresource",
@@ -1712,7 +1713,7 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1722,7 +1723,7 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -1730,7 +1731,7 @@ dependencies = [
[[package]]
name = "filmcraft-apv"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1740,14 +1741,14 @@ dependencies = [
[[package]]
name = "filmcraft-audio-dsp"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"filmcraft-engine",
@@ -1763,7 +1764,7 @@ dependencies = [
[[package]]
name = "filmcraft-av1"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1774,21 +1775,21 @@ dependencies = [
[[package]]
name = "filmcraft-avi"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-bitstream"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"proptest",
]
[[package]]
name = "filmcraft-captions"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@@ -1801,11 +1802,11 @@ dependencies = [
[[package]]
name = "filmcraft-cfb"
version = "0.10.0"
version = "0.12.0"
[[package]]
name = "filmcraft-cli"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-automation",
"filmcraft-codecs",
@@ -1827,7 +1828,7 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
@@ -1865,7 +1866,7 @@ dependencies = [
[[package]]
name = "filmcraft-color"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -1873,7 +1874,7 @@ dependencies = [
[[package]]
name = "filmcraft-dng"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-dng",
"rayon",
@@ -1882,7 +1883,7 @@ dependencies = [
[[package]]
name = "filmcraft-dnx"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1892,7 +1893,7 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-geom",
"filmcraft-media",
@@ -1905,7 +1906,7 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"craft-docpath",
"filmcraft-audio-dsp",
@@ -1940,7 +1941,7 @@ dependencies = [
[[package]]
name = "filmcraft-export"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-aac",
"filmcraft-apv",
@@ -1971,7 +1972,7 @@ dependencies = [
[[package]]
name = "filmcraft-flac"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1980,7 +1981,7 @@ dependencies = [
[[package]]
name = "filmcraft-format"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-media",
"filmcraft-project",
@@ -1988,12 +1989,13 @@ dependencies = [
"log",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-frame"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@@ -2003,7 +2005,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -2011,7 +2013,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -2028,7 +2030,7 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-export",
@@ -2047,7 +2049,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2057,7 +2059,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2067,7 +2069,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2077,7 +2079,7 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@@ -2094,7 +2096,7 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2105,7 +2107,7 @@ dependencies = [
[[package]]
name = "filmcraft-matroska"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
@@ -2113,7 +2115,7 @@ dependencies = [
[[package]]
name = "filmcraft-media"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-dng",
@@ -2129,7 +2131,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpa"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2138,7 +2140,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpeg2v"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"rayon",
@@ -2147,7 +2149,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -2155,28 +2157,28 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-ogg"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-opus"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-platform"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-codecs",
@@ -2202,7 +2204,7 @@ dependencies = [
[[package]]
name = "filmcraft-project"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2216,7 +2218,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2226,7 +2228,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -2246,7 +2248,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"serde",
@@ -2255,7 +2257,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2273,14 +2275,14 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"png",
]
[[package]]
name = "filmcraft-text"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"harfrust",
@@ -2292,7 +2294,7 @@ dependencies = [
[[package]]
name = "filmcraft-time"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -2300,7 +2302,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2314,7 +2316,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts-text"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"serde",
"thiserror 2.0.21",
@@ -2322,7 +2324,7 @@ dependencies = [
[[package]]
name = "filmcraft-ui-egui"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"cpu-time",
"craft-ui",
@@ -2361,23 +2363,25 @@ dependencies = [
[[package]]
name = "filmcraft-vp8"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-vp9"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-web"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -7402,7 +7406,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
+1 -1
View File
@@ -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"
+4
View File
@@ -8,6 +8,10 @@
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png" width="128" height="128" alt="FilmCraft icon: an engraved owl on a purple tile">
</p>
<h1 align="center">FilmCraft</h1>
<p align="center">
+17 -3
View File
@@ -69,7 +69,9 @@ USAGE
SUBCOMMANDS
exec <id> [key=value ...] run one command; prints its result as JSON
exec <id> '<json params>' same, params as one JSON object
run <script.jsonl | -> run commands, one {\"id\",\"params\"} per line (# comments ok)
run <script.jsonl | -> run commands, one {\"id\",\"params\"} per line (# comments ok), in
order: file.exportMedia waits for its file before the next line
(and before exit) unless its params say \"wait\": false
commands [filter] [--json] list commands (id, label, shortcut, params)
describe <id> one command as JSON (menu, shortcut, params, enabled now)
inspect [project|sequence] project tree or active sequence as JSON (default: both)
@@ -169,9 +171,21 @@ impl Backend {
Backend::Local(Box::new(s))
}
async fn exec(&mut self, id: &str, params: Value) -> Result<Value, String> {
async fn exec(&mut self, id: &str, mut params: Value) -> Result<Value, String> {
match self {
Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()),
// this process exits after the command, so a background export would never be written:
// long commands wait unless the caller says otherwise (#2342)
Backend::Local(s) => {
if filmcraft_automation::long_job::LONG_COMMANDS.contains(&id)
&& let Some(p) = params.as_object_mut()
{
// anything but an explicit `false` (missing, null, a typo'd value) waits
if p.get("wait").and_then(Value::as_bool) != Some(false) {
p.insert("wait".into(), json!(true));
}
}
s.execute(id, params).map_err(|e| e.to_string())
}
// a blocking export runs as an app job polled by the client (#91, #92)
Backend::Bridge(b) => b.execute(id, params).await.map_err(|e| e.to_string()),
}
+18 -1
View File
@@ -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]
+47
View File
@@ -352,6 +352,24 @@ impl Services for WebServices {
Ok(())
}
fn write_file_new(&self, path: &str, data: &[u8]) -> io::Result<()> {
if path.starts_with("/opfs/") {
return Err(io::Error::new(io::ErrorKind::Unsupported, "exclusive OPFS writes are unavailable"));
}
let hook = FS.with(|fs| {
let mut fs = fs.borrow_mut();
if fs.entries.contains_key(path) {
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
fs.entries.insert(path.to_owned(), Entry::Mem(Arc::from(data)));
Ok(fs.on_write.clone())
})?;
if let Some(h) = hook {
h(path, data);
}
Ok(())
}
fn file_size(&self, path: &str) -> io::Result<u64> {
Self::entry_reader(path).map(|r| r.len())
}
@@ -435,3 +453,32 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
});
Ok(())
}
#[cfg(test)]
mod exclusive_write_tests {
use super::*;
#[test]
fn virtual_file_creation_is_exclusive_and_opfs_fails_closed() {
let host = WebServices;
let existing = "/files/collision-test.wav";
let fresh = "/files/new-extraction-test.wav";
FS.with(|fs| {
let mut fs = fs.borrow_mut();
fs.on_write = None;
fs.entries.insert(existing.into(), Entry::Mem(Arc::from(&b"original"[..])));
fs.entries.remove(fresh);
});
assert_eq!(host.write_file_new(existing, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert_eq!(host.read_file(existing).unwrap(), b"original");
host.write_file_new(fresh, b"new").unwrap();
assert_eq!(host.read_file(fresh).unwrap(), b"new");
assert_eq!(host.write_file_new(fresh, b"again").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert_eq!(host.write_file_new("/opfs/media/new.wav", b"new").unwrap_err().kind(), io::ErrorKind::Unsupported);
FS.with(|fs| {
let mut fs = fs.borrow_mut();
fs.entries.remove(existing);
fs.entries.remove(fresh);
});
}
}
+2
View File
@@ -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"
+2 -1
View File
@@ -9,7 +9,8 @@ Usage: filmcraft [options] [project.fcproj | media files...]
Options:
--control <port> start the localhost JSON-lines control server
(also: FILMCRAFT_CONTROL_PORT)
(also: FILMCRAFT_CONTROL_PORT; requires FILMCRAFT_CONTROL_TOKEN,
64 random hex characters; see docs/control-protocol.md)
--control-input-only accept widget input only from the control server
(requires --control or FILMCRAFT_CONTROL_PORT)
--demo open the demo project
+128 -15
View File
@@ -4,7 +4,9 @@
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
//! never reaches the UI thread. See `docs/control-protocol.md`.
//! never reaches the UI thread. Every request must carry the session credential from
//! `FILMCRAFT_CONTROL_TOKEN`; without a configured credential the listener stays closed.
//! See `docs/control-protocol.md`.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
@@ -15,6 +17,7 @@ use std::time::Duration;
use filmcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
use subtle::ConstantTimeEq;
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
/// anything longer is refused and the connection closed.
@@ -23,7 +26,17 @@ pub const MAX_LINE: usize = 4 * 1024 * 1024;
pub const MAX_CONNECTIONS: usize = 16;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
start_with_token(port, ctx, std::env::var("FILMCRAFT_CONTROL_TOKEN").ok())
}
fn start_with_token(port: u16, ctx: egui::Context, token: Option<String>) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
// An inherited environment variable shares the credential with the owner's bridge process
// without exposing it in command-line arguments, logs, or a shared discovery file.
let Some(token) = token.filter(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())) else {
eprintln!("filmcraft: control server disabled: FILMCRAFT_CONTROL_TOKEN must contain 64 random hex characters (see docs/control-protocol.md)");
return rx;
};
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -32,11 +45,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
};
eprintln!("filmcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
std::thread::spawn(move || accept_loop(listener, tx, ctx, token));
rx
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context, token: String) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
@@ -46,12 +59,13 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
}
let tx = tx.clone();
let ctx = ctx.clone();
let token = token.clone();
// The slot is given back when the guard drops: after `serve` returns, if it panics, or if
// the thread can't be started (the closure, and with it the guard, is dropped).
let slot = Slot(Arc::clone(&open));
let spawned = std::thread::Builder::new().name("control-conn".into()).spawn(move || {
let _slot = slot;
serve(stream, &tx, &ctx);
serve(stream, &tx, &ctx, &token);
});
if let Err(e) = spawned {
eprintln!("filmcraft: control server could not start a connection thread: {e}");
@@ -68,9 +82,9 @@ impl Drop for Slot {
}
}
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, token: &str) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
serve_lines(read, stream, token, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
tx.send(req).ok()?;
ctx.request_repaint();
@@ -90,7 +104,7 @@ enum Line {
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
fn parse_line(bytes: &[u8], token: &str) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
@@ -108,6 +122,12 @@ fn parse_line(bytes: &[u8]) -> Line {
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
// Authenticate every request before constructing or enqueueing a ControlRequest. A valid
// earlier request must not authorize a later one with a missing or incorrect credential.
let authenticated = obj.get("token").and_then(Value::as_str).is_some_and(|supplied| bool::from(supplied.as_bytes().ct_eq(token.as_bytes())));
if !authenticated {
return Line::Reject("control authentication failed; closing the connection".into());
}
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
}
@@ -138,14 +158,14 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
/// returns the reply object (the request's `id` is added here).
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, token: &str, mut handle: impl FnMut(String, Value) -> Option<Value>) {
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Ok(true) => match parse_line(&buf, token) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
@@ -169,10 +189,12 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
serve_lines(input, &mut out, |m, _p| {
serve_lines(input, &mut out, TOKEN, |m, _p| {
calls.push(m);
Some(json!({"ok": true, "result": null}))
});
@@ -182,7 +204,10 @@ mod tests {
#[test]
fn requests_are_answered_in_order_with_ids() {
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
let input = format!(
"{{\"token\":\"{TOKEN}\",\"id\":1,\"method\":\"a\"}}\n\n{{\"token\":\"{TOKEN}\",\"id\":2,\"method\":\"b\",\"params\":{{}}}}\r\n{{\"token\":\"{TOKEN}\",\"method\":\"c\"}}"
);
let (r, calls) = run(input.as_bytes());
assert_eq!(calls, ["a", "b", "c"]);
assert_eq!(r.len(), 3);
assert_eq!(r[0]["id"], 1);
@@ -220,7 +245,8 @@ mod tests {
#[test]
fn a_request_before_junk_still_runs() {
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
let input = format!("{{\"token\":\"{TOKEN}\",\"method\":\"a\"}}\nGET / HTTP/1.1\n{{\"token\":\"{TOKEN}\",\"method\":\"b\"}}\n");
let (r, calls) = run(input.as_bytes());
assert_eq!(calls, ["a"]);
assert_eq!(r.len(), 2);
assert_eq!(r[1]["ok"], false);
@@ -256,11 +282,11 @@ mod tests {
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
serve_lines(read, s, TOKEN, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
c.write_all(format!("{{\"token\":\"{TOKEN}\",\"id\":7,\"method\":\"x\"}}\nhello\n{{\"token\":\"{TOKEN}\",\"method\":\"y\"}}\n").as_bytes()).unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
server.join().unwrap();
@@ -275,7 +301,7 @@ mod tests {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, _rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default(), TOKEN.into()));
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
@@ -285,4 +311,91 @@ mod tests {
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
#[test]
fn missing_or_invalid_token_leaves_the_listener_closed() {
for token in [None, Some(String::new()), Some("a".repeat(63)), Some("a".repeat(65)), Some("z".repeat(64))] {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let rx = start_with_token(port, egui::Context::default(), token);
assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));
let _still_available = TcpListener::bind(("127.0.0.1", port)).unwrap();
}
}
#[test]
fn unauthenticated_save_as_closes_before_any_command_runs() {
for token in
[Value::Null, json!(false), json!(42), json!([]), json!({}), json!(""), json!("a".repeat(64)), json!(&TOKEN[..63]), json!(format!("{TOKEN}0"))]
{
let mut request = json!({"id": 1, "method": "engine.execute", "params": {"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}}});
if !token.is_null() {
request["token"] = token;
}
// A valid request after a rejected one must never execute, either.
let input = format!("{request}\n{}\n", json!({"token": TOKEN, "method": "ui.inspect"}));
let (replies, calls) = run(input.as_bytes());
assert!(calls.is_empty());
assert_eq!(replies, [json!({"ok": false, "error": "control authentication failed; closing the connection"})]);
}
}
#[test]
fn authentication_is_required_on_every_request() {
let input = format!(
"{}\n{}\n{}\n",
json!({"token": TOKEN, "method": "ui.inspect"}),
json!({"method": "app.quit"}),
json!({"token": TOKEN, "method": "app.quit"})
);
let (replies, calls) = run(input.as_bytes());
assert_eq!(calls, ["ui.inspect"]);
assert_eq!(replies.len(), 2);
assert_eq!(replies[1]["ok"], false);
}
#[test]
fn tcp_authentication_happens_before_enqueueing() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
serve(s, &tx, &egui::Context::default(), TOKEN);
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"method\":\"engine.execute\",\"params\":{\"command\":\"file.saveAs\",\"params\":{\"path\":\"/owner/project.fcproj\"}}}\n").unwrap();
c.shutdown(std::net::Shutdown::Write).unwrap();
let mut reply = String::new();
c.read_to_string(&mut reply).unwrap();
server.join().unwrap();
assert!(reply.contains("control authentication failed"));
assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));
}
#[test]
fn authenticated_tcp_request_reaches_the_ui_queue() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
serve(s, &tx, &egui::Context::default(), TOKEN);
});
let params = json!({"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
writeln!(c, "{}", json!({"id": 7, "token": TOKEN, "method": "engine.execute", "params": params})).unwrap();
c.shutdown(std::net::Shutdown::Write).unwrap();
let request = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(request.method, "engine.execute");
assert_eq!(request.params, params);
request.reply.send(json!({"ok": true})).unwrap();
let mut reply = String::new();
c.read_to_string(&mut reply).unwrap();
server.join().unwrap();
assert_eq!(serde_json::from_str::<Value>(&reply).unwrap(), json!({"id": 7, "ok": true}));
}
}
+2
View File
@@ -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
}
+1
View File
@@ -8,6 +8,7 @@
//! (the demo project), Open Most Recent, or an empty project.
//!
//! `--control <port>` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! `FILMCRAFT_CONTROL_TOKEN` is required (see `docs/control-protocol.md`).
//! see `filmcraft_ui_egui::control` for the methods. A port that is not a number is an error from
//! either source.
//!
+33 -3
View File
@@ -19,6 +19,15 @@ fn accel(s: &str) -> Option<Accelerator> {
mapped.parse().ok()
}
/// The app menu's Quit item: the `app.quit` command (which asks to save unsaved changes) on its
/// shortcut, Cmd+Q unless remapped in the Keyboard Shortcuts editor. The key equivalent is read
/// once, when the menu bar is built: `app.quit` has no menu path, so it is not in the menu tree the
/// updater receives, and a remap shows on the Quit item after the next launch.
fn quit_item(app: &FilmcraftApp) -> (&'static str, Option<Accelerator>) {
let keys = app.session.shortcuts.primary("app.quit").unwrap_or_else(|| "Cmd+Q".into());
("app.quit", accel(&keys))
}
/// Updates native item labels, key equivalents and enablement when the menu tree changes.
pub type MenuUpdater = Box<dyn FnMut(&[Item])>;
@@ -53,9 +62,12 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
let predefined = [
(hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))),
(hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))),
(quit_label, PredefinedMenuItem::quit(Some(app.ui.language.tr(quit_label)))),
];
let [(_, hide), (_, hide_others), (_, quit)] = &predefined;
let [(_, hide), (_, hide_others)] = &predefined;
// Quit is ours, not the predefined item: that one calls `terminate:` and skips the unsaved
// changes prompt (#1943); the `app.quit` command closes the window through it
let (quit_id, quit_accel) = quit_item(app);
let quit = MenuItem::with_id(quit_id, app.ui.language.tr(quit_label), true, quit_accel);
let _ = app_menu.append_items(&[
&about,
&discord,
@@ -65,7 +77,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
hide,
hide_others,
&PredefinedMenuItem::separator(),
quit,
&quit,
]);
let _ = bar.append(&app_menu);
for top in MENUS {
@@ -147,6 +159,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
for (label, item) in &predefined {
item.set_text(language.tr(label));
}
quit.set_text(language.tr(quit_label));
for (shown, mi) in &mut native {
let Some(it) = items.iter().find(|i| i.id == shown.id) else { continue };
if it.label != shown.label {
@@ -164,3 +177,20 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
});
(rx, update)
}
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_engine::Session;
/// #1943: Cmd+Q must reach the `app.quit` command, which closes the window through the unsaved
/// changes prompt, instead of the predefined Quit item's `terminate:`.
#[test]
fn quit_item_runs_the_app_quit_command_on_cmd_q() {
let app = FilmcraftApp::new(Session::default());
let (id, accelerator) = quit_item(&app);
assert_eq!(id, "app.quit");
assert_eq!(accelerator, accel("Cmd+Q"));
assert!(accelerator.is_some(), "Cmd+Q is a native key equivalent");
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
//! truncation at (nearly) every length must never panic or run away (errors are fine).
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_apv::*;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
fn seeds() -> Vec<Vec<u8>> {
let mut v = Vec::new();
for (p, w, h) in [(Profile::P422_10, 64u32, 48u32), (Profile::P444_10, 48, 32), (Profile::P4444_10, 32, 32), (Profile::P400_10, 32, 32)] {
let chroma = p.default_chroma();
let depth = p.default_bit_depth();
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
for (i, s) in f.y.iter_mut().enumerate() {
*s = (64 + (i * 37) % 800) as u16;
}
let mut cfg = EncoderConfig::new(p, w, h);
cfg.qp = Some(16);
let Ok(mut enc) = Encoder::with_config(cfg) else { continue };
if let Ok(d) = enc.encode(&f) {
v.push(d);
}
if let Ok(d) = enc.encode_raw_au(&f) {
v.push(d);
}
}
v
}
fn run(d: &[u8], label: &str) {
filmcraft_testkit::set_progress(label);
let res = catch_unwind(AssertUnwindSafe(|| {
for depth in [None, Some(8), Some(16)] {
let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() });
}
let _ = probe(d);
let _ = split_raw_bitstream(d);
}));
assert!(res.is_ok(), "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_frames_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let seeds = seeds();
assert!(!seeds.is_empty());
for (si, seed) in seeds.iter().enumerate() {
let stride = (seed.len() / 400).max(1);
for n in (0..seed.len()).step_by(stride) {
run(&seed[..n], &format!("seed {si} truncate {n}"));
}
for p in 0..seed.len().min(256) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run(&d, &format!("seed {si} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p}.{b}"));
}
}
// Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions).
for p in 0..seed.len().min(128) {
for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] {
let mut d = seed.clone();
if let Some(s) = d.get_mut(p..p + 4) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u32 {p}={v:#x}"));
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000);
for i in 0..iters {
let mut d = seed.clone();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
run(&d, &format!("seed {si} random {i}"));
}
}
}
+1 -1
View File
@@ -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"] }
+21 -3
View File
@@ -20,6 +20,8 @@ type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWr
pub struct BridgeClient {
addr: String,
/// Inherited from the same owner-controlled environment as the desktop app.
token: Option<String>,
/// Connections with no request in flight. A request takes one (or opens one) for its whole
/// round trip and puts it back only once the reply is read, so a slow or abandoned request
/// never holds up the others or leaves its late reply for the next one to read (#1538).
@@ -28,14 +30,15 @@ pub struct BridgeClient {
}
impl BridgeClient {
/// `addr` such as `127.0.0.1:9876` (loopback only).
/// `addr` such as `127.0.0.1:9876` (loopback only). Reads `FILMCRAFT_CONTROL_TOKEN`
/// from the environment; the desktop app requires this credential on every request.
pub fn new(addr: impl Into<String>) -> Result<Self, AutomationError> {
let addr = addr.into();
let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr);
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
}
Ok(Self { addr, idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
Ok(Self { addr, token: std::env::var("FILMCRAFT_CONTROL_TOKEN").ok(), idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
}
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
@@ -85,7 +88,7 @@ impl BridgeClient {
None => self.connect().await?,
};
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
let line = format!("{}\n", json!({"id": id, "token": self.token, "method": method, "params": params}));
let res: Result<Value, AutomationError> = async {
conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;
let mut buf = String::new();
@@ -282,4 +285,19 @@ mod tests {
assert_eq!(seen.len(), 2);
assert_eq!(seen[1]["params"]["command"], "sequence.inspect");
}
#[tokio::test]
async fn credential_is_sent_on_every_request() {
let (addr, seen) = fake_app(Value::Null).await;
let mut b = BridgeClient::new(addr).unwrap();
// Inject directly to avoid mutating the process environment in parallel tests.
let token = "0123456789abcdef".repeat(4);
b.token = Some(token.clone());
b.call("ui.inspect", json!({})).await.unwrap();
b.execute("file.saveAs", json!({"path": "/owner/project.fcproj"})).await.unwrap();
let seen = seen.lock().await;
assert_eq!(seen.len(), 2);
assert!(seen.iter().all(|request| request["token"] == token));
assert_eq!(seen[1]["params"]["params"]["path"], "/owner/project.fcproj");
}
}
+2 -2
View File
@@ -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();
+7
View File
@@ -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
+35 -1
View File
@@ -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));
}
}
+4
View File
@@ -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;
+156
View File
@@ -0,0 +1,156 @@
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
//! ffmpeg-generated AV1 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use filmcraft_av1::Decoder;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
/// (name, encoder args, pix_fmt)
const SEEDS: &[(&str, &str, &str)] = &[("av1_fz_default", "-c:v libsvtav1 -g 4", "yuv420p"), ("av1_fz_10bit", "-c:v libsvtav1 -g 6", "yuv420p10le")];
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let Some(h) = data.get(6..8) else { return out };
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
while let Some(hd) = data.get(pos..pos + 12) {
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
pos += 12;
let end = pos.saturating_add(size).min(data.len());
out.push(data[pos..end].to_vec());
pos = end;
}
out
}
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
let out = filmcraft_testkit::fixtures_dir("av1").join(format!("{name}.ivf"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
.args(a)
.args(["-f", "ivf"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
Some(ivf_frames(&std::fs::read(p).ok()?))
}
static PANICS: AtomicUsize = AtomicUsize::new(0);
/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test.
fn count_panics() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::panic::set_hook(Box::new(|info| {
PANICS.fetch_add(1, Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(frames: &[Vec<u8>], label: &str) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for threads in [1, 3] {
let mut dec = Decoder::with_threads(threads);
for (i, f) in frames.iter().enumerate() {
let _ = dec.decode_pts(f, i as i64);
}
let _ = dec.flush_result();
}
}));
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("AV1 fuzz") else { return };
for &(name, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libsvtav1") {
filmcraft_testkit::skip("AV1 fuzz", &format!("this ffmpeg has no libsvtav1 encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, args, pix) else {
panic!("ffmpeg has libsvtav1 but could not encode the {name} seed");
};
if seed.is_empty() {
filmcraft_testkit::skip("AV1 fuzz", &format!("the {name} seed has no frames"));
continue;
}
// truncate each of the first frames at every length
for fi in 0..seed.len().min(3) {
let len = seed[fi].len();
for n in (0..len).step_by((len / 200).max(1)) {
let mut d = seed.clone();
d[fi].truncate(n);
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
}
}
// header region of every frame: every byte to a few values, every bit
for fi in 0..seed.len() {
for p in 0..seed[fi].len().min(96) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[fi][p] = v;
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[fi][p] ^= 1 << b;
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
}
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let fi = rng.below(d.len());
if d[fi].is_empty() {
continue;
}
let p = rng.below(d[fi].len());
if rng.below(2) == 0 {
d[fi][p] ^= 1 << rng.below(8);
} else {
d[fi][p] = rng.next() as u8;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
+161
View File
@@ -0,0 +1,161 @@
//! A few bytes must not make the decoder allocate gigabytes: oversized sequences are refused.
//! Hand-built (no ffmpeg): a reduced still-picture sequence header plus a lossless key frame whose
//! tiles are one zero byte each.
use filmcraft_av1::{Decoder, Error};
#[derive(Default)]
struct Bits {
bytes: Vec<u8>,
n: usize,
}
impl Bits {
fn put(&mut self, value: u32, bits: usize) {
for i in (0..bits).rev() {
if self.n.is_multiple_of(8) {
self.bytes.push(0);
}
if (value >> i) & 1 == 1
&& let Some(b) = self.bytes.last_mut()
{
*b |= 0x80 >> (self.n % 8);
}
self.n += 1;
}
}
}
fn obu(kind: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![kind << 3 | 0b10];
let mut n = payload.len();
loop {
let b = (n & 0x7f) as u8;
n >>= 7;
v.push(if n > 0 { b | 0x80 } else { b });
if n == 0 {
break;
}
}
v.extend_from_slice(payload);
v
}
fn tile_log2(blk: u32, target: u32) -> u32 {
let mut k = 0;
while (blk << k) < target {
k += 1;
}
k
}
/// Temporal delimiter, reduced still-picture sequence header of `w` x `h` (64x64 superblocks) and a
/// lossless key frame whose uniformly spaced tiles are one zero byte each.
fn stream(w: u32, h: u32) -> Vec<u8> {
let mut s = Bits::default();
s.put(0, 3); // seq_profile
s.put(1, 1); // still_picture
s.put(1, 1); // reduced_still_picture_header
s.put(0, 5); // seq_level_idx
s.put(15, 4); // frame_width_bits_minus_1
s.put(15, 4); // frame_height_bits_minus_1
s.put(w - 1, 16);
s.put(h - 1, 16);
s.put(0, 3 + 3 + 7 + 1); // sb size, filter intra, edge; superres, cdef, restoration; color config; film grain
s.put(1, 1); // trailing bit
let (sb_cols, sb_rows) = (w.div_ceil(64), h.div_ceil(64));
let cols_log2 = tile_log2(64, sb_cols); // MAX_TILE_WIDTH / 64 superblocks
let min_log2_tiles = cols_log2.max(tile_log2(2304, sb_rows * sb_cols));
let rows_log2 = min_log2_tiles.saturating_sub(cols_log2);
let tiles = (sb_cols.div_ceil(sb_cols.div_ceil(1 << cols_log2))) * (sb_rows.div_ceil(sb_rows.div_ceil(1 << rows_log2)));
let mut f = Bits::default();
f.put(0b0001, 4); // disable_cdf_update, allow_screen_content_tools, render_and_frame_size_different, uniform_tile_spacing_flag
f.put(0, 2); // no increment of the tile column / row counts
if tiles > 1 {
f.put(0, (cols_log2 + rows_log2) as usize + 2); // context_update_tile_id, tile_size_bytes_minus_1
}
f.put(0, 8 + 4 + 1 + 1); // base_q_idx 0 (lossless), delta flags, using_qmatrix, segmentation_enabled, reduced_tx_set
let mut frame = f.bytes;
frame.push(0); // tile_start_and_end_present_flag
for _ in 1..tiles {
frame.extend([0, 0]); // tile_size_minus_1 = 0, one data byte
}
frame.extend([0, 0]);
let mut out = obu(2, &[]);
out.extend(obu(1, &s.bytes));
out.extend(obu(6, &frame));
out
}
fn refused(w: u32, h: u32) {
let mut dec = Decoder::new();
let r = dec.decode(&stream(w, h)).and_then(|_| dec.flush_result());
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("frame size")), "{w}x{h} must be refused as too large, got {:?}", r.as_ref().map(|p| p.len()));
}
#[test]
fn very_wide_frame_is_refused() {
refused(65536, 16);
}
#[test]
fn very_tall_frame_is_refused() {
refused(16, 65536);
}
#[test]
fn huge_frames_are_refused_without_allocating() {
refused(65536, 65536);
refused(65535, 65535);
refused(30000, 30000);
}
/// Frames past the level 6.x limits (width 16384, height 8704, area 35 651 584) are refused. The accepted side of
/// each boundary is unit-tested on `frame_size_allowed` in `decoder.rs`: decoding a maximum-size frame end to end
/// needs gigabytes.
#[test]
fn frames_past_the_level_6_limits_are_refused() {
refused(16384, 2177);
refused(16384, 8704);
refused(4096, 8705);
}
/// A 64x48 4:2:0 key frame whose sequence profile bit was flipped to 2 (4:2:2): blocks such as 4x16 then have no
/// chroma size (BLOCK_INVALID) and used to index the size tables out of bounds.
const PROFILE_FLIPPED: &[u8] = &[
0x12, 0x00, 0x0a, 0x0b, 0x42, 0x00, 0x00, 0x05, 0x55, 0x7f, 0xbc, 0x6a, 0xf9, 0x00, 0x40, 0x32, 0x81, 0x05, 0x14, 0x00, 0xa5, 0xa0, 0x40, 0xd9, 0x52, 0xb9,
0xe7, 0x3c, 0xa6, 0xc1, 0xe4, 0xe9, 0xff, 0x6d, 0x12, 0xc3, 0xce, 0xac, 0x00, 0x92, 0x6f, 0x29, 0x6a, 0xee, 0x7e, 0x1a, 0x31, 0xfe, 0xe6, 0xb8, 0x2e, 0xe2,
0xcf, 0x3d, 0xce, 0x24, 0xe6, 0x30, 0x03, 0xcd, 0x89, 0x49, 0x1f, 0xa7, 0x8a, 0x75, 0xb3, 0x93, 0x27, 0x7b, 0x2f, 0x39, 0xc7, 0x60, 0xb7, 0xf8, 0x69, 0xbc,
0x53, 0x71, 0x30, 0x64, 0x20, 0x7e, 0xc1, 0x10, 0x17, 0xe4, 0xca, 0x34, 0xe9, 0xae, 0x47, 0x39, 0x68, 0x68, 0xef, 0x33, 0x9f, 0x26, 0xe2, 0x3f, 0xf1, 0xef,
0x02, 0xb7, 0xfd, 0xb1, 0x78, 0x4c, 0x35, 0x31, 0x17, 0x27, 0x11, 0x2f, 0xd7, 0x25, 0x01, 0x30, 0xa5, 0x7a, 0xaf, 0x9b, 0x5b, 0x3b, 0x8d, 0x9e, 0xd5, 0x23,
0x06, 0x22, 0x3a, 0xec, 0x57, 0xae, 0xdb, 0x4d, 0x00, 0x36, 0xff, 0xbd, 0x3b, 0xef, 0x0a, 0xf0, 0x39, 0x2a, 0xa9, 0x2a, 0x56, 0x9d, 0x7e, 0xc2, 0x05, 0x46,
0x21, 0x8b, 0xd9, 0x45, 0x33, 0x59, 0x3c, 0x40, 0xe0, 0x2b, 0xec, 0x51, 0x0a, 0xf2, 0xf0, 0xe5, 0x61, 0x99, 0xfd, 0x2e, 0xe5, 0x8c, 0x69, 0x72, 0x44, 0xb8,
0x92, 0x9e, 0x2e, 0x51, 0x0b, 0x8c, 0x16, 0x0f, 0xd9, 0xf2, 0x47, 0xc4, 0x36, 0x9d, 0x54, 0xc6, 0x9f, 0x26, 0x68, 0xf7, 0xfa, 0x80, 0xab, 0x68, 0xd6, 0x61,
0xd2, 0x59, 0x4b, 0x07, 0xc4, 0x7a, 0xde, 0x37, 0xfb, 0x02, 0xd5, 0x3d, 0xb0, 0xeb, 0x71, 0x1b, 0x07, 0x45, 0xdc, 0x03, 0x3b, 0x4f, 0xdf, 0x44, 0xa6, 0x28,
0xd4, 0xb0, 0xbb, 0x26, 0xa4, 0x4f, 0xee, 0xbb, 0xda, 0x59, 0x69, 0xfc, 0xf2, 0x4f, 0xf6, 0x6c, 0x0c, 0xd9, 0xe2, 0xc0, 0xa7, 0x64, 0x96, 0xd5, 0x7b, 0x1a,
0x71, 0xe0, 0xcc, 0x9e, 0xd7, 0xaf, 0xb9, 0x9a, 0x99, 0xcc, 0xe9, 0xa9, 0x79, 0xa3, 0xa8, 0xbc, 0xfe, 0xdb, 0xed, 0xa3, 0xd6, 0x7d, 0x31, 0xb0, 0x18, 0x3c,
0x9e, 0x55, 0x0f, 0x1a, 0xdf, 0x5d, 0x8c, 0x33, 0x5f, 0x2d, 0x00, 0xfc, 0x9d, 0x55, 0xc2, 0xc8, 0x10, 0x25, 0xc8, 0x7f, 0xa6, 0x47, 0xbf, 0xab, 0xf3, 0x97,
0xfb, 0xda, 0xd6, 0x6a, 0x14, 0x87, 0x9d, 0xef, 0x85, 0x12, 0x43, 0x31, 0xc4, 0x4c, 0x4c, 0x7a, 0x50, 0xd1, 0x87, 0xb8, 0x65, 0x2d, 0x3f, 0xf4, 0xa3, 0x54,
0xea, 0x19, 0xa6, 0xfe, 0x70, 0x30, 0x52, 0x7e, 0xbb, 0x9c, 0x3f, 0x2d, 0x10, 0x4b, 0x43, 0x04, 0x6c, 0x03, 0xc7, 0x15, 0xa8, 0x8e, 0x20, 0xde, 0x46, 0x9a,
0x12, 0xdf, 0xe5, 0xce, 0x08, 0x91, 0x3e, 0x74, 0xa4, 0x20, 0x78, 0x52, 0xfd, 0x4d, 0x9d, 0x5a, 0xca, 0xa2, 0xdc, 0xc8, 0x95, 0xa6, 0x69, 0x3a, 0x1a, 0x18,
0x48, 0x76, 0x8c, 0xfa, 0x58, 0xa0, 0x0d, 0xf4, 0xbd, 0xe1, 0x05, 0x37, 0xe6, 0xbb, 0x60, 0xa1, 0x83, 0x40, 0x08, 0xeb, 0x02, 0xa6, 0x6a, 0x7a, 0x16, 0x1f,
0xb3, 0xbb, 0x58, 0x66, 0x51, 0x79, 0xc7, 0x7a, 0xb2, 0xc2, 0x84, 0x69, 0x17, 0x4d, 0x27, 0xa7, 0xa4, 0xd9, 0xc2, 0xb4, 0x03, 0x57, 0x53, 0x44, 0xb9, 0x8f,
0x48, 0xd2, 0x1c, 0x74, 0xd9, 0xdd, 0x9b, 0x88, 0xdc, 0xda, 0xef, 0x70, 0xee, 0xf1, 0x04, 0xdf, 0x35, 0x39, 0x22, 0x49, 0xfe, 0x22, 0x9a, 0x0a, 0x33, 0x60,
0x49, 0xbe, 0x28, 0xfa, 0xdf, 0x71, 0xf6, 0x53, 0x87, 0x49, 0x01, 0x95, 0xfa, 0x22, 0x94, 0x7c, 0x0e, 0xf6, 0x7d, 0x7e, 0x1d, 0x41, 0x8d, 0xc9, 0x1b, 0xf9,
0x53, 0xc9, 0xea, 0x46, 0xee, 0xb2, 0x80, 0x75, 0xe1, 0xca, 0xa4, 0x0a, 0x9c, 0x2b, 0x7b, 0x18, 0x70, 0x56, 0x22, 0x76, 0xaf, 0x4d, 0x84, 0xdd, 0xad, 0x77,
0x0e, 0xea, 0x2e, 0xa9, 0xbf, 0x42, 0x95, 0xdf, 0x17, 0x61, 0xae, 0x46, 0x20, 0xb4, 0x57, 0xca, 0xe0, 0x78, 0x86, 0xd0, 0x51, 0xae, 0xfe, 0x51, 0x53, 0x0f,
0x36, 0x64, 0x5f, 0xb4, 0x5c, 0xac, 0x8b, 0x63, 0xe7, 0x5d, 0x98, 0x8e, 0x65, 0xe9, 0x93, 0xe4, 0x23, 0x10, 0xa3, 0x5b, 0x3b, 0x38, 0x5b, 0x8a, 0x7c, 0xe8,
0x60, 0x01, 0xc1, 0x0e, 0x53, 0x5e, 0x0e, 0x81, 0x33, 0x63, 0x8d, 0x53, 0x82, 0xa6, 0x7b, 0xbd, 0x02, 0x58, 0x1f, 0xe9, 0x50, 0x75, 0x24, 0x64, 0x80, 0x47,
0x02, 0x9e, 0x42, 0x28, 0xb0, 0xa2, 0xab, 0x8e, 0xd0, 0x81, 0xcc, 0xbd, 0x22, 0x87, 0x3f, 0xe4, 0x49, 0x4d, 0x5f, 0x6a, 0x5c, 0x5a, 0x5e, 0xe5, 0x30, 0x9d,
0x7f, 0xc5, 0xaf, 0xce, 0x6c, 0x9c, 0xaf, 0x09, 0x96, 0x1a, 0x62, 0x42, 0x61, 0xa3, 0x1d, 0x1c, 0xe6, 0xcf, 0xa8, 0x90, 0x4d, 0x05, 0x28, 0xef, 0x22, 0x81,
0x12, 0xa7, 0x70, 0x04, 0x0a, 0x0d, 0xe5, 0xa0, 0xd8,
];
#[test]
fn block_without_chroma_size_is_an_error_not_a_panic() {
let mut dec = Decoder::new();
let r = dec.decode(PROFILE_FLIPPED).and_then(|_| dec.flush_result());
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("chroma size")), "got {:?}", r.as_ref().map(|p| p.len()));
}
+10
View File
@@ -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"))
+12 -1
View File
@@ -356,7 +356,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?;
let (v, unit) = s.split_at(unit_start);
let (vi, vf) = v.split_once('.').unwrap_or((v, ""));
if !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 {
if vi.is_empty() || !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 {
return None;
}
let t = match unit {
@@ -758,6 +758,17 @@ mod tests {
}
}
#[test]
fn offset_time_without_integer_digits_is_rejected() {
let t = tm(FrameRate::FPS_25);
for expr in ["s", "ms", "h", "m", "t", "f", ".5s", ".5ms", ".s"] {
assert_eq!(parse_time(expr, t), None, "{expr}");
}
assert_eq!(parse_time("0s", t), Some(Tick(0)));
assert_eq!(parse_time("0.5s", t), Some(Tick(TICKS_PER_SECOND / 2)));
assert_eq!(parse_time("1ms", t), Some(Tick(TICKS_PER_SECOND / 1000)));
}
#[test]
fn reads_styles_spans_and_nesting() {
let x = r#"<?xml version="1.0"?>
+119 -12
View File
@@ -21,10 +21,15 @@ use crate::{CodecError, make_video_decoder};
/// Most bytes read from one AAC access unit when probing for HE-AAC at open (8 channels × 6144 bits).
const MAX_AAC_PROBE_UNIT: usize = 8 * 6144 / 8;
struct DecodedAudioPacket {
samples: Vec<Vec<f32>>,
failed: bool,
}
struct AudioState {
decoder: Option<PacketDecoder>,
/// Decoded packets by sample index.
packets: HashMap<usize, Arc<Vec<Vec<f32>>>>,
packets: HashMap<usize, Arc<DecodedAudioPacket>>,
order: Vec<usize>,
last_decoded: Option<usize>,
}
@@ -92,6 +97,20 @@ fn audio_starts(file: &Mp4File, bytes: &crate::Src, ti: usize, rate: u32) -> Vec
contiguous_starts(packets, (frame / 2).max(unit.saturating_mul(2).saturating_add(1)))
}
/// Return source-rate buffer indices overlapped by a failed packet's concealment span.
/// Packet positions are audio frames, but duration is raw track-timescale ticks
/// treated as a frame count here; the span is clipped to the request.
fn failed_audio_span(packet_start: i64, request_start: i64, duration: u32, request_len: usize) -> std::ops::Range<usize> {
let begin = usize::try_from(packet_start.saturating_sub(request_start).max(0)).unwrap_or(usize::MAX).min(request_len);
let end = if duration == 0 {
begin
} else {
let last = packet_start.saturating_add(i64::from(duration) - 1).saturating_sub(request_start);
usize::try_from((i128::from(last) + 1).max(0)).unwrap_or(usize::MAX).min(request_len)
};
begin..end
}
/// HDR static metadata from the sample entry's `mdcv` / `clli` boxes (0 = unknown).
fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u16, u16)>) -> Option<filmcraft_color::HdrMetadata> {
if md.is_none() && cll.is_none() {
@@ -366,18 +385,18 @@ impl Mp4Source {
}
/// Decoded packet `i` of audio stream `a` (`ainfo` is its `AudioStreamInfo`; `st` its decoder state).
fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result<Arc<Vec<Vec<f32>>>> {
fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result<Arc<DecodedAudioPacket>> {
if let Some(p) = st.packets.get(&i) {
return Ok(p.clone());
}
let ti = a.track;
let track = self.file.tracks.get(ti).ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
let entry = track.entries.first().ok_or_else(|| CodecError::Container("audio track without a sample description".into()))?;
let sample = track.samples.get(i).ok_or_else(|| CodecError::Container("audio packet out of range".into()))?;
track.samples.get(i).ok_or_else(|| CodecError::Container("audio packet out of range".into()))?;
let pk_start = a.starts.get(i).copied().unwrap_or(0);
let data = self.read(ti, i)?;
let decoded = match &entry.codec {
CodecConfig::Pcm(p) => decode_pcm(&data, p),
let (samples, failed) = match &entry.codec {
CodecConfig::Pcm(p) => (decode_pcm(&data, p), false),
c => {
if st.decoder.is_none() {
st.decoder = Some(PacketDecoder::for_isobmff(c, ainfo.sample_rate)?);
@@ -398,15 +417,15 @@ impl Mp4Source {
}
}
}
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64);
let result = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64);
st.last_decoded = Some(i);
match r {
Ok(v) => v,
Err(_) => vec![vec![0.0; sample.duration as usize]; ainfo.channels.max(1) as usize],
match result {
Ok(samples) => (samples, false),
Err(_) => (Vec::new(), true),
}
}
};
let p = Arc::new(decoded);
let p = Arc::new(DecodedAudioPacket { samples, failed });
st.packets.insert(i, p.clone());
st.order.push(i);
if st.order.len() > 4096 {
@@ -476,14 +495,24 @@ impl MediaSource for Mp4Source {
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
let first = a.starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1);
let mut i = first;
while i < track.samples.len() {
while let Some(sample) = track.samples.get(i) {
let Some(&pk_start) = a.starts.get(i) else { break };
if pk_start >= s0.saturating_add(need) {
break;
}
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
if pk.failed {
let range = failed_audio_span(pk_start, s0, sample.duration, src.first().map_or(0, Vec::len));
for dst in &mut src {
if let Some(span) = dst.get_mut(range.clone()) {
span.fill(0.0);
}
}
i += 1;
continue;
}
for (c, dst) in src.iter_mut().enumerate() {
let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue };
let Some(chan) = pk.samples.get(c.min(pk.samples.len().saturating_sub(1))) else { continue };
for (k, v) in chan.iter().enumerate() {
let pos = pk_start.saturating_add(k as i64).saturating_sub(s0);
if pos >= 0 && (pos as usize) < dst.len() {
@@ -1318,3 +1347,81 @@ mod he_aac_tests {
}
}
}
#[cfg(test)]
mod corrupt_audio_tests {
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use filmcraft_media::MediaSource;
#[test]
fn failed_packet_span_matches_per_sample_silence_at_extreme_timestamps() {
let min = i64::MIN;
let max = i64::MAX;
for start in [min, min + 1, min + 2, -10, -1, 0, 1, 10, max - 2, max - 1, max] {
for request in [min, min + 1, min + 2, -10, -1, 0, 1, 10, max - 2, max - 1, max] {
for duration in [0, 1, 2, 5, 128] {
let old: Vec<_> = (0..duration)
.map(|k| start.saturating_add(i64::from(k)).saturating_sub(request))
.filter(|&position| (0..8).contains(&position))
.map(|position| position as usize)
.collect();
let range = super::failed_audio_span(start, request, duration, 8);
let actual: Vec<_> = (0..8).filter(|position| range.contains(position)).collect();
let expected: Vec<_> = old.into_iter().collect::<std::collections::BTreeSet<_>>().into_iter().collect();
assert_eq!(actual, expected, "start={start}, request={request}, duration={duration}");
}
}
}
assert_eq!(super::failed_audio_span(0, 0, u32::MAX, 128), 0..128);
}
#[test]
fn failed_aac_packet_does_not_materialize_its_hostile_duration() {
// AAC-LC, 48 kHz stereo. The empty access unit cannot decode.
let asc = vec![0x11, 0x90];
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
let track = mux.add_track(TrackConfig::new(SampleEntry::aac(asc, 2, 48_000), 48_000)).unwrap();
mux.write_sample(track, WriteSample { data: &[], duration: 1_000_000, composition_offset: 0, is_sync: true }).unwrap();
let source = super::Mp4Source::open("damaged.m4a", mux.finish().unwrap().into_inner().into()).unwrap();
let audio = &source.audios[0];
let info = &source.info.audio_streams[0];
let mut state = audio.state.lock().unwrap();
let packet = source.audio_packet(audio, info, &mut state, 0).unwrap();
assert!(packet.failed);
assert!(packet.samples.is_empty(), "failed packet must not allocate from its untrusted duration");
drop(state);
let window = source.audio(0, 128, 48_000).unwrap();
assert_eq!(window.channels.len(), 2);
assert!(window.channels.iter().all(|ch| ch.len() == 128 && ch.iter().all(|&v| v == 0.0)));
}
#[test]
fn failed_aac_packet_silences_overlap_independent_of_request_boundary() {
let rate = 48_000u32;
let tone: Vec<f32> = (0..8192).map(|i| 0.3 * (i as f32 * 0.13).sin()).collect();
let mut encoder = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(rate, 1, 64_000)).unwrap();
let mut units = encoder.encode(&[&tone]);
units.extend(encoder.flush());
assert!(units.len() >= 2);
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
let track = mux.add_track(TrackConfig::new(SampleEntry::aac(vec![0x11, 0x88], 1, rate), rate)).unwrap();
mux.write_sample(track, WriteSample { data: &units[0], duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
mux.write_sample(track, WriteSample { data: &units[1], duration: 400, composition_offset: 0, is_sync: true }).unwrap();
mux.write_sample(track, WriteSample { data: &[], duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
let source = super::Mp4Source::open("overlap.m4a", mux.finish().unwrap().into_inner().into()).unwrap();
// The failed packet starts at 1024 + 400 = 1424, after 124 frames of the crossing read.
let crossing = source.audio(1300, 256, rate).unwrap();
let isolated = source.audio(1424, 132, rate).unwrap();
let prior_packet_len = {
let audio = &source.audios[0];
let info = &source.info.audio_streams[0];
let mut state = audio.state.lock().unwrap();
source.audio_packet(audio, info, &mut state, 1).unwrap().samples.first().map_or(0, Vec::len)
};
assert!(prior_packet_len > 400, "valid packet must overlap the failed packet");
assert!(crossing.channels[0][..124].iter().any(|v| v.abs() > 0.001));
assert!(isolated.channels[0].iter().all(|&v| v == 0.0));
assert_eq!(&crossing.channels[0][124..], &isolated.channels[0]);
}
}
+2
View File
@@ -38,6 +38,8 @@ for limited- and full-range files.
| ARRI LogC4 | ARRI Wide Gamut 4 | ARRI, *ARRI LogC4 Logarithmic Color Space — Specification* (2022) | CV | a = (2¹⁸ − 16)/117.45, b = 928/1023, c = 95/1023, s = 7·ln2·2^(7 − 14c/b)/(a·b), t = (2^(6 − 14c/b) − 64)/a; y ≥ 0: x = (2^(14(y − c)/b + 6) − 64)/a; else x = y·s + t |
| Apple Log | BT.2020 | Apple, *Apple Log Profile* white paper (2023) | IRE | R0 = −0.05641088, Rt = 0.01, c = 47.28711236, β = 0.00964052, γ = 0.08550479, δ = 0.69336945; P ≥ c(Rt − R0)²: x = 2^((P − δ)/γ) − β; else x = √(P/c) + R0 |
| DJI D-Log | D-Gamut | DJI, *White Paper on D-Log and D-Gamut of DJI Cinema Color System* (2017) | CV | y ≤ 0.14: x = (y − 0.0929)/6.025; else x = (10^(3.89616y − 2.27752) − 0.0108)/0.9892 |
| Fujifilm F-Log | F-Gamut (= BT.2020 primaries) | Fujifilm, *GFX ETERNA Technical Guide – White Paper* v1.01 (2026), F-Log conversion formula (same as the F-Log data sheet) | CV | y ≥ cut2: x = 10^((y − d)/c)/a − b/a; else x = (y − f)/e; a 0.555556, b 0.009468, c 0.344676, d 0.790453, e 8.735631, f 0.092864, cut1 0.00089, cut2 0.100537775223865 |
| Fujifilm F-Log2 / F-Log2 C | F-Gamut / F-Gamut C (R 0.7347, 0.2653; G 0.0263, 0.9737; B 0.1173, −0.0224) | same document, F-Log2 C / F-Log2 conversion formula | CV | same form; a 5.555556, b 0.064829, c 0.245281, d 0.384316, e 8.799461, f 0.092864, cut1 0.000889, cut2 0.100686685370811 |
The encoders are the exact inverses given in the same documents. DJI **D-Log M** is not
implemented: DJI has not published its formula (only conversion LUTs), and we do not derive curves
+61 -2
View File
@@ -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]
+25 -2
View File
@@ -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),
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
//! truncation at (nearly) every length and random mutation of encoder-made frames must never
//! panic or run away (errors are fine). No ffmpeg needed.
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with, probe};
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
fn seeds() -> Vec<Vec<u8>> {
let mut v = Vec::new();
for (profile, depth, w, h) in
[(Profile::Lb, 8, 64u32, 48u32), (Profile::Hq, 8, 48, 32), (Profile::Hqx, 10, 64, 32), (Profile::Hqx, 12, 48, 32), (Profile::R444, 10, 48, 32)]
{
let mut cfg = EncoderConfig::new(profile, w, h);
cfg.bit_depth = depth;
let chroma = cfg.chroma();
let mut f = Frame::new(w, h, chroma, depth, false);
for (i, s) in f.y.iter_mut().enumerate() {
*s = ((16 + (i * 37) % 200) as u16) << (depth - 8);
}
if chroma != ChromaFormat::Yuv444 {
f.cb.fill(128 << (depth - 8));
f.cr.fill(100 << (depth - 8));
}
if let Ok(mut enc) = Encoder::with_config(cfg)
&& let Ok(d) = enc.encode(&f)
{
v.push(d);
}
}
v
}
fn run(d: &[u8], label: &str) {
filmcraft_testkit::set_progress(label);
let res = catch_unwind(AssertUnwindSafe(|| {
let _ = probe(d);
for opts in [DecodeOptions::default(), DecodeOptions { threads: true, spec_dequant: true }] {
let _ = decode_frame_with(d, &opts);
}
}));
assert!(res.is_ok(), "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_frames_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let seeds = seeds();
assert!(!seeds.is_empty());
for (si, seed) in seeds.iter().enumerate() {
let stride = (seed.len() / 400).max(1);
for n in (0..seed.len()).step_by(stride) {
run(&seed[..n], &format!("seed {si} truncate {n}"));
}
// The header (and the start of the scan-line index): every byte to extreme values, every bit.
for p in 0..seed.len().min(0x300) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run(&d, &format!("seed {si} byte {p:#x}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p:#x}.{b}"));
}
}
// Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C).
for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] {
let mut d = seed.clone();
if let Some(s) = d.get_mut(off..off + 2) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u16 {off:#x}={v:#x}"));
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1500);
for i in 0..iters {
let mut d = seed.clone();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
run(&d, &format!("seed {si} random {i}"));
}
}
}
+11 -1
View File
@@ -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")
+44
View File
@@ -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");
+85 -4
View File
@@ -1,5 +1,7 @@
//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO, AAF, OMF) ↔ the session's project.
use std::path::Path;
use serde_json::{Value, json};
use filmcraft_interchange::{ExportOptions, Format, ImportOptions};
@@ -24,6 +26,32 @@ fn is_file_media(item: &filmcraft_project::ProjectItem) -> bool {
matches!(&item.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::File { .. }))
}
/// Write embedded audio without replacing an occupied path; return the path used.
fn write_embedded_audio(s: &Session, path: &str, wav: &[u8]) -> Result<String> {
let original = Path::new(path);
let stem = original.file_stem().ok_or_else(|| EngineError::Other(format!("{path}: missing audio file name")))?;
for number in 1..=1_000 {
let candidate = if number == 1 {
original.to_path_buf()
} else {
let mut name = stem.to_os_string();
name.push(format!(" ({number})"));
if let Some(extension) = original.extension() {
name.push(".");
name.push(extension);
}
original.with_file_name(name)
};
let candidate_text = candidate.to_string_lossy();
match s.services.write_file_new(&candidate_text, wav) {
Ok(()) => return Ok(candidate_text.into_owned()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(EngineError::Other(format!("{candidate_text}: {e}"))),
}
}
Err(EngineError::Other(format!("{path}: no unused embedded-audio filename after 1000 attempts")))
}
/// Import a document: merge its bins, media and sequences into the project (one undo step), then
/// link each media file that exists on disk. Returns the new sequences.
pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result<Value> {
@@ -37,12 +65,17 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
// AAF / OMF may embed audio: write it next to the document (the media items point there)
Format::Aaf | Format::Omf => {
let r = if format == Format::Aaf { filmcraft_interchange::aaf::import(bytes, &opts) } else { filmcraft_interchange::omf::import(bytes, &opts) };
let (fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let (mut fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
for m in extracted {
if let Some(dir) = std::path::Path::new(&m.path).parent().filter(|_| !s.services.export_in_memory()) {
let _ = std::fs::create_dir_all(dir);
let media = fragment
.project
.item_mut(m.item)
.and_then(|item| item.as_media_mut())
.ok_or_else(|| EngineError::Other(format!("{}: embedded audio has no media item", m.path)))?;
let written_path = write_embedded_audio(s, &m.path, &m.wav)?;
if written_path != m.path {
media.media = MediaRef::File { path: written_path };
}
s.services.write_file(&m.path, &m.wav).map_err(|e| EngineError::Other(format!("{}: {e}", m.path)))?;
}
(fragment, report)
}
@@ -152,3 +185,51 @@ pub fn export(s: &mut Session, p: &Value) -> Result<Value> {
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(e.to_string()))?;
Ok(json!({"path": path, "bytes": bytes.len(), "report": report_json(&report)}))
}
#[cfg(test)]
mod embedded_audio_tests {
use std::collections::HashMap;
use std::io;
use std::sync::{Arc, Mutex};
use crate::{Services, Session};
use super::write_embedded_audio;
#[derive(Default)]
struct VirtualFiles(Mutex<HashMap<String, Vec<u8>>>);
impl Services for VirtualFiles {
fn read_file(&self, path: &str) -> io::Result<Vec<u8>> {
self.0.lock().unwrap().get(path).cloned().ok_or_else(|| io::ErrorKind::NotFound.into())
}
fn write_file(&self, path: &str, data: &[u8]) -> io::Result<()> {
self.0.lock().unwrap().insert(path.to_owned(), data.to_vec());
Ok(())
}
fn write_file_new(&self, path: &str, data: &[u8]) -> io::Result<()> {
let mut files = self.0.lock().unwrap();
if files.contains_key(path) {
return Err(io::ErrorKind::AlreadyExists.into());
}
files.insert(path.to_owned(), data.to_vec());
Ok(())
}
}
#[test]
fn virtual_extraction_keeps_existing_media() {
let files = Arc::new(VirtualFiles::default());
files.0.lock().unwrap().insert("/files/cut Audio.wav".into(), b"first".to_vec());
files.0.lock().unwrap().insert("/files/cut Audio (2).wav".into(), b"second".to_vec());
let session = Session::new(files.clone());
let path = write_embedded_audio(&session, "/files/cut Audio.wav", b"new audio").unwrap();
assert_eq!(path, "/files/cut Audio (3).wav");
let stored = files.0.lock().unwrap();
assert_eq!(stored["/files/cut Audio.wav"], b"first");
assert_eq!(stored["/files/cut Audio (2).wav"], b"second");
assert_eq!(stored["/files/cut Audio (3).wav"], b"new audio");
}
}
+25
View File
@@ -102,6 +102,14 @@ pub type Result<T> = std::result::Result<T, EngineError>;
pub trait Services: Send + Sync {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>>;
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>;
/// Publish a complete new file only when the final path is unoccupied. Publication must
/// reject a concurrent collision; a prior existence check alone is insufficient.
/// Return `ErrorKind::AlreadyExists` for an occupied final path so the importer can retry
/// with a numbered name; return other errors without publishing a partial file.
/// Hosts without this operation fail closed.
fn write_file_new(&self, _path: &str, _data: &[u8]) -> std::io::Result<()> {
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "exclusive file creation is unavailable on this host"))
}
/// Size of a file in bytes (an error when it is missing). Hosts should override this: the
/// default reads the whole file.
fn file_size(&self, path: &str) -> std::io::Result<u64> {
@@ -159,6 +167,23 @@ impl Services for FsServices {
// Atomic + durable: temp file in the same directory, fsync, rename, fsync the directory.
filmcraft_format::atomic_write(std::path::Path::new(path), data)
}
fn write_file_new(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
let path = std::path::Path::new(path);
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| std::path::Path::new("."));
std::fs::create_dir_all(dir).map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists {
std::io::Error::new(std::io::ErrorKind::NotADirectory, format!("{} is not a directory: {error}", dir.display()))
} else {
error
}
})?;
match std::fs::symlink_metadata(path) {
Ok(_) => return Err(std::io::ErrorKind::AlreadyExists.into()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
filmcraft_format::atomic_write_new(path, data)
}
fn file_size(&self, path: &str) -> std::io::Result<u64> {
let m = std::fs::metadata(path)?;
if m.is_file() { Ok(m.len()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("{path} is not a file"))) }
+3
View File
@@ -17,5 +17,8 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "3.27"
[dev-dependencies]
filmcraft-time = { workspace = true }
+91 -2
View File
@@ -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");
+56 -13
View File
@@ -1,9 +1,10 @@
//! Auto-save file naming and rotation (Premiere behaviour).
//!
//! Auto-saves go to an `Auto-Save` folder next to the project, named
//! `<project name>-YYYY-MM-DD_HH-MM-SS.fcproj` (local wall-clock time). After each write the oldest
//! files of that project beyond the "Maximum Project Versions" limit are deleted. Only files whose
//! name is exactly `<name>-<timestamp>.fcproj` are ever touched.
//! `<project name>-YYYY-MM-DD_HH-MM-SS-<unix seconds>.fcproj` (local wall-clock time, then the real
//! time that orders the files). After each write the oldest files of that project beyond the "Maximum
//! Project Versions" limit are deleted. Only files whose name is exactly `<name>-<timestamp>.fcproj`
//! or `<name>-<timestamp>-<unix seconds>.fcproj` are ever touched.
use std::fs;
use std::io;
@@ -55,9 +56,10 @@ pub fn display_time(unix: i64, offset_secs: i32) -> String {
format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}")
}
/// `<stem>-YYYY-MM-DD_HH-MM-SS.fcproj`.
/// `<stem>-YYYY-MM-DD_HH-MM-SS-<unix seconds>.fcproj`. The local stamp is for people; the unix time
/// orders the files (the local clock can go back, or repeat an hour, when daylight saving time ends).
pub fn auto_save_name(name: &str, unix: i64, offset_secs: i32) -> String {
format!("{}-{}.fcproj", file_stem(name), timestamp(unix, offset_secs))
format!("{}-{}-{unix}.fcproj", file_stem(name), timestamp(unix, offset_secs))
}
fn is_stamp(s: &str) -> bool {
@@ -70,20 +72,33 @@ fn is_stamp(s: &str) -> bool {
})
}
/// Auto-saves of project `name` in `dir`, oldest first.
/// Parse `YYYY-MM-DD_HH-MM-SS` or `YYYY-MM-DD_HH-MM-SS-<unix>` into the stamp and the unix time
/// (`None` for files written before the unix time was part of the name).
fn parse_stamp(s: &str) -> Option<(&str, Option<i64>)> {
if is_stamp(s) {
return Some((s, None));
}
let stamp = s.get(..19).filter(|st| is_stamp(st))?;
let digits = s.get(19..)?.strip_prefix('-')?;
let unix: i64 = digits.parse().ok()?;
(unix.to_string() == digits).then_some((stamp, Some(unix)))
}
/// Auto-saves of project `name` in `dir`, oldest first (by real time, not by the local clock; files
/// without a unix time in their name are older than those with one).
pub fn list_auto_saves(dir: &Path, name: &str) -> Vec<PathBuf> {
let prefix = format!("{}-", file_stem(name));
let Ok(rd) = fs::read_dir(dir) else { return Vec::new() };
let mut v: Vec<(String, PathBuf)> = rd
let mut v: Vec<(Option<i64>, String, PathBuf)> = rd
.filter_map(|e| e.ok())
.filter_map(|e| {
let f = e.file_name().to_string_lossy().into_owned();
let stamp = f.strip_prefix(&prefix)?.strip_suffix(".fcproj")?;
is_stamp(stamp).then(|| (stamp.to_string(), e.path()))
let (stamp, unix) = parse_stamp(f.strip_prefix(&prefix)?.strip_suffix(".fcproj")?)?;
Some((unix, stamp.to_string(), e.path()))
})
.collect();
v.sort();
v.into_iter().map(|(_, p)| p).collect()
v.into_iter().map(|(_, _, p)| p).collect()
}
/// Delete the oldest auto-saves of `name` so at most `max` remain. Returns the deleted paths.
@@ -124,12 +139,40 @@ mod tests {
#[test]
fn names_and_stems() {
assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00.fcproj");
assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00-0.fcproj");
assert_eq!(file_stem("a/b:c"), "a_b_c");
assert_eq!(file_stem(" "), "Untitled");
assert_eq!(auto_save_dir(Path::new("/x/y/p.fcproj")), Path::new("/x/y/Auto-Save"));
}
#[test]
fn rotation_survives_the_clock_going_back_at_the_end_of_dst() {
let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
let t0 = 1_792_900_000;
// 02:30 at +02:00, then 40 minutes later the clock reads 02:10 at +01:00.
let old = write_auto_save(&d, "Cut", t0, 7200, b"old", 1).unwrap();
let new = write_auto_save(&d, "Cut", t0 + 2400, 3600, b"new", 1).unwrap();
assert!(new.exists(), "the newest auto-save was deleted by rotation");
assert!(!old.exists(), "the oldest auto-save should be rotated out");
fs::remove_dir_all(&d).unwrap();
}
#[test]
fn repeated_wall_clock_hour_does_not_overwrite_earlier_saves() {
let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst2-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
let t0 = 1_792_900_000;
// The same wall-clock second, one hour apart (+02:00 then +01:00).
let a = write_auto_save(&d, "Cut", t0, 7200, b"first", 5).unwrap();
let b = write_auto_save(&d, "Cut", t0 + 3600, 3600, b"second", 5).unwrap();
assert_ne!(a, b);
assert_eq!(fs::read(&a).unwrap(), b"first");
assert_eq!(fs::read(&b).unwrap(), b"second");
assert_eq!(list_auto_saves(&d, "Cut"), vec![a, b]);
fs::remove_dir_all(&d).unwrap();
}
#[test]
fn rotation_keeps_newest_and_ignores_other_files() {
let d = std::env::temp_dir().join(format!("filmcraft-rotate-{}", std::process::id()));
@@ -142,8 +185,8 @@ mod tests {
fs::write(d.join("notes.txt"), b"keep").unwrap();
let left = list_auto_saves(&d, "Cut");
assert_eq!(left.len(), 5);
assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_120, 0))));
assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_360, 0))));
assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}-1790000120.fcproj", timestamp(1_790_000_120, 0))));
assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}-1790000360.fcproj", timestamp(1_790_000_360, 0))));
assert_eq!(list_auto_saves(&d, "Cut-Two").len(), 1);
assert!(d.join("notes.txt").exists());
rotate(&d, "Cut", 2).unwrap();
+1 -1
View File
@@ -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";
+136
View File
@@ -0,0 +1,136 @@
//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and
//! random mutation of small libx264 streams must never panic or run away. Skips without ffmpeg.
mod common;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use filmcraft_h264::Decoder;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
/// (name, extra x264 args, pix_fmt)
const SEEDS: &[(&str, &str, &str)] = &[
("h264_fz_cabac_b", "-profile:v high -bf 2 -g 8 -x264-params cabac=1:weightp=2:8x8dct=1", "yuv420p"),
("h264_fz_cavlc", "-profile:v baseline -g 6 -x264-params cabac=0", "yuv420p"),
("h264_fz_interlaced", "-profile:v high -flags +ildct+ilme -bf 1 -g 6 -x264-params tff=1:interlaced=1", "yuv420p"),
("h264_fz_10bit_422", "-profile:v high422 -bf 1 -g 6", "yuv422p10le"),
("h264_fz_multislice", "-profile:v main -g 6 -x264-params slices=3:cabac=1", "yuv420p"),
];
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<u8>> {
let out = filmcraft_testkit::fixtures_dir("h264").join(format!("{name}.h264"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", "libx264"])
.args(a)
.args(["-f", "h264"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
std::fs::read(p).ok()
}
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test.
fn count_panics() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::panic::set_hook(Box::new(|info| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(d: &[u8], label: &str) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(std::sync::atomic::Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for threads in [1, 3] {
let mut dec = Decoder::with_threads(threads);
for au in common::split_access_units(d) {
let _ = dec.decode(au, 0);
}
let _ = dec.decode(d, 1);
let _ = dec.flush();
let _ = dec.take_error();
}
}));
assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("h264 fuzz") else { return };
for &(name, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libx264") {
filmcraft_testkit::skip("h264 fuzz", &format!("this ffmpeg has no libx264 encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, args, pix) else {
panic!("ffmpeg has libx264 but could not encode the {name} seed");
};
let stride = (seed.len() / 300).max(1);
for n in (0..seed.len()).step_by(stride) {
run_all(&seed[..n], &format!("{name} truncate {n}"));
}
// SPS/PPS/first slice header region: every byte to a few values, every bit
for p in 0..seed.len().min(120) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(400)
}
+137
View File
@@ -0,0 +1,137 @@
//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and
//! random mutation of small libx265 streams must never panic or run away. Skips without ffmpeg.
mod common;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use filmcraft_hevc::Decoder;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
/// (name, encoder, extra encoder args, pix_fmt)
const SEEDS: &[(&str, &str, &str, &str)] = &[
("hevc_fz_main_b", "libx265", "-x265-params log-level=error:bframes=2:keyint=8:min-keyint=8:sao=1:amp=1", "yuv420p"),
("hevc_fz_main10", "libx265", "-x265-params log-level=error:bframes=1:keyint=6:min-keyint=6", "yuv420p10le"),
("hevc_fz_wpp_slices", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6:wpp=1:slices=3", "yuv420p"),
// x265 cannot encode tiles; kvazaar can, at 256 x 64 luma per tile at least (Main profile)
("hevc_fz_tiles_kvazaar", "libkvazaar", "-s 512x128 -kvazaar-params tiles=2x2,gop=0,period=6,qp=40", "yuv420p"),
("hevc_fz_422_10", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6", "yuv422p10le"),
];
fn make(ff: &std::path::Path, name: &str, enc: &str, args: &str, pix: &str) -> Option<Vec<u8>> {
let out = filmcraft_testkit::fixtures_dir("hevc").join(format!("{name}.hevc"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", enc])
.args(a)
.args(["-f", "hevc"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
std::fs::read(p).ok()
}
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test.
fn count_panics() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::panic::set_hook(Box::new(|info| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(d: &[u8], label: &str) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(std::sync::atomic::Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for threads in [1, 3] {
let mut dec = Decoder::with_threads(threads);
for au in common::split_access_units(d) {
let _ = dec.decode(au, 0);
}
let _ = dec.decode(d, 1);
let _ = dec.flush();
let _ = dec.take_error();
}
}));
assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("hevc fuzz") else { return };
for &(name, enc, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, enc) {
filmcraft_testkit::skip("hevc fuzz", &format!("this ffmpeg has no {enc} encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, enc, args, pix) else {
panic!("ffmpeg has {enc} but could not encode the {name} seed");
};
let stride = (seed.len() / 300).max(1);
for n in (0..seed.len()).step_by(stride) {
run_all(&seed[..n], &format!("{name} truncate {n}"));
}
// SPS/PPS/first slice header region: every byte to a few values, every bit
for p in 0..seed.len().min(120) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(400)
}
+11 -1
View File
@@ -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")
+11 -1
View File
@@ -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");
+3
View File
@@ -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
+6 -1
View File
@@ -163,6 +163,9 @@ struct Seq {
/// MPEG-1 pel_aspect_ratio (height/width of a sample) ×10000, Table 2-D.2.
const MPEG1_PEL_ASPECT: [u32; 15] = [0, 10000, 6735, 7031, 7615, 8055, 8437, 8935, 9157, 9815, 10255, 10695, 10950, 11575, 12015];
/// Largest accepted luma area (8192 x 4096; 8K UHD is 7680 x 4320).
const MAX_PICTURE_AREA: u64 = 1 << 25;
impl Seq {
fn new(hdr: SequenceHeader, ext: Option<SequenceExtension>, display: Option<DisplayExtension>) -> Result<Seq> {
let (w, h) = match &ext {
@@ -175,7 +178,9 @@ impl Seq {
3 => ChromaFormat::Yuv444,
_ => return Err(Error::Invalid("reserved chroma_format".into())),
};
if w > 16383 || h > 16383 {
// The 14-bit sizes allow 16383 x 16383 (gigabytes per frame, several frames and a gray reference
// held); no real stream exceeds 8K, so cap the area there.
if w > 16383 || h > 16383 || u64::from(w) * u64::from(h) > MAX_PICTURE_AREA {
return Err(Error::Unsupported(format!("{w}x{h}")));
}
let progressive_sequence = ext.is_none_or(|e| e.progressive_sequence);
+142
View File
@@ -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)
}
+73
View File
@@ -0,0 +1,73 @@
//! A 14-byte sequence header must not make the decoder size its frame buffers at 16383 x 16383
//! (gigabytes per picture). Hand-built, no ffmpeg.
use filmcraft_mpeg2v::Decoder;
#[derive(Default)]
struct Bits {
bytes: Vec<u8>,
n: usize,
}
impl Bits {
fn put(&mut self, value: u32, bits: usize) {
for i in (0..bits).rev() {
if self.n.is_multiple_of(8) {
self.bytes.push(0);
}
if (value >> i) & 1 == 1
&& let Some(b) = self.bytes.last_mut()
{
*b |= 0x80 >> (self.n % 8);
}
self.n += 1;
}
}
}
/// Sequence header plus sequence extension for a `w` x `h` 4:4:4 stream.
fn sequence(w: u32, h: u32, chroma_format: u32) -> Vec<u8> {
let mut s = Bits::default();
s.put(w & 0xfff, 12);
s.put(h & 0xfff, 12);
s.put(1, 4); // aspect_ratio_information
s.put(4, 4); // frame_rate_code
s.put(1000, 18); // bit_rate_value
s.put(1, 1); // marker
s.put(100, 10); // vbv_buffer_size_value
s.put(0, 3); // constrained_parameters_flag, load_intra, load_non_intra
let mut e = Bits::default();
e.put(1, 4); // sequence_extension
e.put(0x48, 8); // profile_and_level_indication
e.put(1, 1); // progressive_sequence
e.put(chroma_format, 2);
e.put(w >> 12, 2);
e.put(h >> 12, 2);
e.put(0, 12); // bit_rate_extension
e.put(1, 1); // marker
e.put(0, 8 + 1 + 2 + 5); // vbv_buffer_size_extension, low_delay, frame_rate_extension_n/d
let mut out = vec![0, 0, 1, 0xb3];
out.extend(s.bytes);
out.extend([0, 0, 1, 0xb5]);
out.extend(e.bytes);
out
}
fn accepted(w: u32, h: u32, chroma_format: u32) -> bool {
let mut dec = Decoder::new();
let _ = dec.decode(&sequence(w, h, chroma_format), 0);
dec.sequence().is_some()
}
#[test]
fn ordinary_sequences_are_accepted() {
assert!(accepted(1920, 1080, 1));
assert!(accepted(7680, 4320, 3));
}
#[test]
fn gigantic_sequences_are_refused() {
assert!(!accepted(16383, 16383, 3));
assert!(!accepted(16383, 16383, 1));
assert!(!accepted(8192, 8192, 1));
}
+107
View File
@@ -0,0 +1,107 @@
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
//! truncation at (nearly) every length must never panic or run away (errors are fine).
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_prores::*;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
fn seeds() -> Vec<Vec<u8>> {
let mut v = Vec::new();
for (p, w, h) in [(Profile::Proxy, 96u32, 48u32), (Profile::Hq, 70, 38), (Profile::P4444, 64, 32), (Profile::P4444Xq, 48, 20)] {
let chroma = p.chroma();
let mut f = Frame::new(w, h, chroma, 10, chroma == ChromaFormat::Yuv444);
for (i, s) in f.y.iter_mut().enumerate() {
*s = (64 + (i * 37) % 800) as u16;
}
if let Some(a) = f.alpha.as_mut() {
for (i, s) in a.iter_mut().enumerate() {
*s = if i % 5 == 0 { 1023 } else { (i % 1024) as u16 };
}
}
let mut cfg = EncoderConfig::new(p, w, h);
cfg.log2_slice_mbs = (w % 4) as u8;
if let Ok(d) = Encoder::with_config(cfg).encode(&f) {
v.push(d);
}
}
v
}
fn run(d: &[u8], label: &str) {
filmcraft_testkit::set_progress(label);
let res = catch_unwind(AssertUnwindSafe(|| {
for depth in [None, Some(8), Some(16)] {
let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() });
}
let _ = probe(d);
}));
assert!(res.is_ok(), "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_frames_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let seeds = seeds();
assert!(!seeds.is_empty());
for (si, seed) in seeds.iter().enumerate() {
let stride = (seed.len() / 400).max(1);
for n in (0..seed.len()).step_by(stride) {
run(&seed[..n], &format!("seed {si} truncate {n}"));
}
for p in 0..seed.len().min(256) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run(&d, &format!("seed {si} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p}.{b}"));
}
}
// Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions).
for p in 0..seed.len().min(128) {
for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] {
let mut d = seed.clone();
if let Some(s) = d.get_mut(p..p + 4) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u32 {p}={v:#x}"));
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000);
for i in 0..iters {
let mut d = seed.clone();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
run(&d, &format!("seed {si} random {i}"));
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Hang guard for hostile-input sweeps: a test body runs on a worker thread, and a body that does not finish
//! within the budget fails the test (naming the input it was last working on) instead of stalling CI forever.
//! A merely slow body is not a failure, so a loaded runner or slow VM cannot fail a correct decoder.
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Mutex, PoisonError};
use std::time::Duration;
static PROGRESS: Mutex<String> = Mutex::new(String::new());
/// Record the input being worked on, so a hang report can name it. Cheap enough to call per input.
pub fn set_progress(label: &str) {
let mut p = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner);
p.clear();
p.push_str(label);
}
/// Run `f` on a worker thread and wait at most `secs` seconds (`FILMCRAFT_HOSTILE_TIMEOUT_SECS` overrides the
/// budget). A panic in `f` is re-raised here; a timeout panics with the last [`set_progress`] label. A hung
/// worker is abandoned (the failing test process exits anyway).
pub fn with_deadline(secs: u64, f: impl FnOnce() + Send + 'static) {
let secs = std::env::var("FILMCRAFT_HOSTILE_TIMEOUT_SECS").ok().and_then(|v| v.parse::<u64>().ok()).filter(|&n| n > 0).unwrap_or(secs);
run_with_deadline(secs, f);
}
fn run_with_deadline(secs: u64, f: impl FnOnce() + Send + 'static) {
let (tx, rx) = mpsc::channel();
// a failed spawn shows up as "worker gone" below
let _ = std::thread::Builder::new().name("hostile deadline".into()).spawn(move || {
let _ = tx.send(catch_unwind(AssertUnwindSafe(f)));
});
match rx.recv_timeout(Duration::from_secs(secs)) {
Ok(Ok(())) => {}
Ok(Err(payload)) => resume_unwind(payload),
Err(RecvTimeoutError::Timeout) => {
let last = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner).clone();
panic!("hostile-input test did not finish in {secs}s (hang); last input: {last}");
}
Err(RecvTimeoutError::Disconnected) => panic!("hostile-input worker thread could not run"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_slow_body_passes() {
with_deadline(30, || std::thread::sleep(Duration::from_millis(100)));
}
#[test]
#[should_panic(expected = "boom")]
fn a_panic_in_the_body_is_re_raised() {
with_deadline(30, || panic!("boom"));
}
#[test]
#[should_panic(expected = "last input: seed 7 byte 3")]
fn a_hang_fails_and_names_the_input() {
// a body that outlives the 1 s budget; the inner fn, so FILMCRAFT_HOSTILE_TIMEOUT_SECS cannot lengthen it
set_progress("seed 7 byte 3");
run_with_deadline(1, || std::thread::sleep(Duration::from_secs(30)));
}
}
+4 -1
View File
@@ -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
+23
View File
@@ -11,6 +11,7 @@
//! `FILMCRAFT_REQUIRE_ORACLES=1` is set (CI), which turns every skip into a failure.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
/// Environment variable that makes missing oracles a hard failure.
@@ -116,6 +117,19 @@ pub fn ffmpeg_or_skip(context: &str) -> Option<PathBuf> {
tool_or_skip("ffmpeg", ffmpeg(), context)
}
/// Whether `ffmpeg -encoders` lists `encoder` (e.g. `libx265`): lets a test tell "this ffmpeg lacks the encoder"
/// (skip) from "the encoder is there but failed" (a real failure).
pub fn ffmpeg_has_encoder(ffmpeg: &Path, encoder: &str) -> bool {
let Ok(out) = Command::new(ffmpeg).args(["-hide_banner", "-encoders"]).stdin(Stdio::null()).output() else { return false };
encoders_output_lists(&String::from_utf8_lossy(&out.stdout), encoder)
}
/// Pure function of the `ffmpeg -encoders` output: whether `encoder` is one of the listed names.
fn encoders_output_lists(output: &str, encoder: &str) -> bool {
// rows look like ` V....D libx264 libx264 H.264 …`; the name is the second whitespace-separated token
output.lines().any(|line| line.split_whitespace().nth(1) == Some(encoder))
}
/// ffprobe, or `None` after reporting a skip (see [`skip`]).
pub fn ffprobe_or_skip(context: &str) -> Option<PathBuf> {
tool_or_skip("ffprobe", ffprobe(), context)
@@ -177,6 +191,15 @@ pub fn ffmpeg_with_format_or_skip(context: &str, format_name: &str, min_version:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encoder_lookup_matches_the_name_column_only() {
let out = " V....D libx264 libx264 H.264 / AVC\n V....D libx265 libx265 H.265 / HEVC (libx265)\n A....D aac AAC (libx264 inside)\n";
assert!(encoders_output_lists(out, "libx264"));
assert!(encoders_output_lists(out, "libx265"));
assert!(!encoders_output_lists(out, "libsvtav1"));
assert!(!encoders_output_lists(out, "H.264"));
}
use std::ffi::OsString;
fn tmpdir(tag: &str) -> PathBuf {
+40 -2
View File
@@ -95,6 +95,24 @@ pub fn normalize(text: &str, lang: Lang) -> Result<Vec<Token>, NormalizeError> {
out.push(Token::Punct(c));
}
}
'-' if matches!(peek(rest, 1), Some('$' | '€' | '£'))
&& !text
.get(..pos)
.and_then(|before| before.chars().next_back())
.is_some_and(|b| b.is_alphanumeric() || matches!(b, '$' | '€' | '£' | '-')) =>
{
// A negative amount ("-$5"): keep the sign so a loss is not read as a gain.
// A hyphen right after a word or amount ("$10-$20") is a range, not a sign.
pos += 1;
match parse_money(rest.get(1..).unwrap_or(""), lang) {
Some((words, used)) => {
pos += used;
out.push(Token::Word("minus".into()));
push_words(&mut out, &words);
}
None => out.push(Token::Punct(c)),
}
}
_ if c.is_ascii_digit() => match parse_number_cluster(rest, lang) {
Some((tokens, used)) => {
pos += used;
@@ -413,6 +431,8 @@ fn parse_time(rest: &str) -> Option<(Vec<Token>, usize)> {
for (lit, letter_pair) in [("am", ['a', 'm']), ("pm", ['p', 'm'])] {
if after.get(..2).is_some_and(|s| s.eq_ignore_ascii_case(lit)) {
for letter in letter_pair {
// Uppercase "A" so it is read as a letter name, not the article.
let letter = if letter == 'a' { 'A' } else { letter };
tokens.push(Token::Word(letter.into()));
}
used += 2;
@@ -944,7 +964,7 @@ fn parse_wordish(rest: &str, _lang: Lang) -> Option<(Vec<Token>, usize)> {
if word.len() == 1 && matches!(lower.as_str(), "a" | "p") && peek(rest, used) == Some('.') && matches!(peek(rest, used + 1), Some('m') | Some('M')) {
let after_m = used + 2;
if peek(rest, after_m).is_none_or(|c| !c.is_alphanumeric()) {
let mut tokens = vec![Token::Word(word.to_ascii_lowercase())];
let mut tokens = vec![Token::Word(if lower == "a" { "A".into() } else { lower.clone() })];
tokens.push(Token::Word("m".into()));
let consumed = if peek(rest, after_m) == Some('.') { after_m + 1 } else { after_m };
return Some((tokens, consumed));
@@ -1237,6 +1257,24 @@ mod tests {
assert_eq!(words("$0.50", Lang::EnUs), "zero dollars and fifty cents");
}
#[test]
fn negative_money_keeps_its_sign() {
for lang in [Lang::EnUs, Lang::EnGb] {
assert_eq!(words("-$5", lang), "minus five dollars");
assert_eq!(words("-€3.50", lang), "minus three euros and fifty cents");
assert_eq!(words("-£2", lang), "minus two pounds");
assert_eq!(words("a refund of -$1.05", lang), "a refund of minus one dollar and five cents");
assert_eq!(words("(-$5)", lang), "( minus five dollars )");
// A hyphen between two amounts is a range, not a sign.
assert_eq!(words("$10-$20", lang), "ten dollars - twenty dollars");
assert_eq!(words("€5-€8", lang), "five euros - eight euros");
assert_eq!(words("$10.50-$20", lang), "ten dollars and fifty cents - twenty dollars");
// A hyphen after a word or before a non-amount stays punctuation.
assert_eq!(words("a-$5", lang), "a - five dollars");
assert!(!words("-$x", lang).contains("minus"));
}
}
#[test]
fn money_fraction_beyond_two_digits_is_not_hundreds_of_cents() {
for lang in [Lang::EnUs, Lang::EnGb] {
@@ -1327,7 +1365,7 @@ mod tests {
assert_eq!(words("23:59", Lang::EnUs), "twenty-three fifty-nine");
assert_eq!(words("3:30pm", Lang::EnUs), "three thirty p m");
assert_eq!(words("3:30 p.m.", Lang::EnUs), "three thirty p m");
assert_eq!(words("9:00 a.m.", Lang::EnUs), "nine zero a m");
assert_eq!(words("9:00 a.m.", Lang::EnUs), "nine zero A m");
}
#[test]
+10
View File
@@ -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 = [
+4 -2
View File
@@ -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:?}");
+98 -5
View File
@@ -232,6 +232,10 @@ pub struct Playback {
/// The audio clock's last reading and when (egui time, s) it last moved: a device that stops
/// consuming samples hands the clock back to the wall clock (see [`AUDIO_STALL_S`]).
audio_seen: (u64, f64),
/// When playback lost its output (a failed open or a dead stream), when it is opened again
/// (egui time, s; 0 = not retrying). Pause/play proves a fresh stream works, so a stream the
/// device keeps dropping is reopened on a timer (see [`AUDIO_RETRY_S`]).
audio_retry_at: f64,
/// Audio underruns for the current (or last) play (desktop: sound is mixed ahead).
#[cfg(not(target_arch = "wasm32"))]
pub audio_stats: std::sync::Arc<play_ahead::AudioStats>,
@@ -256,6 +260,11 @@ pub struct Playback {
/// under this.
pub const AUDIO_STALL_S: f64 = 1.0;
/// How often (s) playback re-opens the output after its stream or device failed, while playing
/// goes on silently on the wall clock. Reopening is what pause/play does by hand; doing it on a
/// timer brings the sound back when the device recovers (#2503).
pub const AUDIO_RETRY_S: f64 = 2.0;
/// How long `ui.screenshot` waits for the window to present the frame.
const SCREENSHOT_TIMEOUT_S: f64 = 10.0;
/// How long `ui.screenshot` waits for the UI to settle (monitors showing their exact frames, the
@@ -770,7 +779,7 @@ impl FilmcraftApp {
let now = ctx.input(|i| i.time);
self.playback.anchor_time = now;
self.playback.audio_seen = (0, now);
self.start_audio();
self.start_audio(now);
}
if let Some(note) = self.audio.as_ref().and_then(|a| a.note()) {
self.ui.status = note;
@@ -1002,6 +1011,7 @@ impl FilmcraftApp {
a.stop();
}
self.playback.audio_clock = false;
self.playback.audio_retry_at = 0.0;
}
/// Start the clock (and audio) once the first frames are ready or the preroll timed out.
@@ -1010,7 +1020,7 @@ impl FilmcraftApp {
self.playback.anchor_time = now;
self.playback.anchor_tick = self.session.playhead();
self.playback.audio_seen = (0, now);
self.start_audio();
self.start_audio(now);
// Audio Track Mixer: an automation pass runs while playing forward in real time
if (self.playback.speed - 1.0).abs() < 1e-9 && !self.session.mixrec.active() {
let t = self.session.playhead();
@@ -1062,6 +1072,7 @@ impl FilmcraftApp {
}
self.scrub.device_stopped();
self.playback.audio_clock = false;
self.playback.audio_retry_at = 0.0;
if self.session.mixrec.active() {
let t = self.session.playhead();
if let Err(e) = self.session.execute("mixer.recordStop", json!({"time": t.0})) {
@@ -1072,7 +1083,7 @@ impl FilmcraftApp {
panels::voiceover::on_stop(self);
}
fn start_audio(&mut self) {
fn start_audio(&mut self, now: f64) {
let Some(seq_id) = self.session.state.active_sequence else { return };
let start_tick = self.session.playhead();
let Some(a) = self.audio.as_mut() else { return };
@@ -1081,6 +1092,7 @@ impl FilmcraftApp {
// either the original rate conversion or pitch-corrected program audio.
let Some((mix_rate, mix)) = shuttle::mix(&self.session, seq_id, sr, self.playback.speed) else {
a.stop();
self.playback.audio_retry_at = 0.0;
return;
};
let cursor = start_tick.to_units_floor(i64::from(mix_rate));
@@ -1100,6 +1112,7 @@ impl FilmcraftApp {
match a.start(fill) {
Ok(_) => {
self.playback.audio_clock = true;
self.playback.audio_retry_at = 0.0;
if let Some(note) = self.audio.as_ref().and_then(|audio| audio.note()) {
self.ui.status = note;
}
@@ -1107,6 +1120,7 @@ impl FilmcraftApp {
Err(e) => {
log::warn!("audio output unavailable: {e}");
self.playback.audio_clock = false;
self.playback.audio_retry_at = now + AUDIO_RETRY_S;
}
}
}
@@ -1129,6 +1143,15 @@ impl FilmcraftApp {
if self.playback.anchor_time < 0.0 {
self.playback.anchor_time = now;
}
if !self.playback.audio_clock && self.playback.audio_retry_at > 0.0 && now >= self.playback.audio_retry_at {
// The output failed mid-play: open it again where the wall clock got to (what
// pause/play does by hand), so a recovering device brings the sound back.
self.playback.audio_retry_at = now + AUDIO_RETRY_S;
self.playback.anchor_tick = self.session.playhead();
self.playback.anchor_time = now;
self.playback.audio_seen = (0, now);
self.start_audio(now);
}
let rate = self.session.sequence_rate();
if self.playback.audio_clock
&& let Some(audio) = self.audio.as_mut()
@@ -1142,6 +1165,7 @@ impl FilmcraftApp {
self.playback.anchor_tick = self.session.playhead();
self.playback.anchor_time = now;
self.playback.audio_clock = false;
self.playback.audio_retry_at = now + AUDIO_RETRY_S;
if let Some(audio) = self.audio.as_mut() {
audio.stop();
}
@@ -1821,7 +1845,8 @@ impl FilmcraftApp {
self.handle_drops(&ctx);
if let Some(rx) = self.command_inbox.take() {
while let Ok(id) = rx.try_recv() {
if self.control_input_only {
// control-only mode ignores native menu commands, but Quit stays available (#1943)
if self.control_input_only && id != "app.quit" {
continue;
}
if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) {
@@ -2477,7 +2502,7 @@ mod audio_recovery_tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
};
struct Device {
@@ -2724,6 +2749,74 @@ mod audio_recovery_tests {
);
app.stop();
}
/// A device whose stream dies mid-play (`ticking`) or whose open fails (`opens`).
struct Reviving {
starts: Arc<AtomicUsize>,
opens: Arc<AtomicBool>,
ticking: Arc<AtomicBool>,
}
impl AudioOut for Reviving {
fn start(&mut self, _: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
self.starts.fetch_add(1, Ordering::Relaxed);
if self.opens.load(Ordering::Relaxed) { Ok(48_000) } else { Err("device unavailable".into()) }
}
fn stop(&mut self) {}
fn sample_rate(&self) -> u32 {
48_000
}
fn played_frames(&self) -> Option<u64> {
self.ticking.load(Ordering::Relaxed).then_some(0)
}
}
#[test]
fn a_lost_output_is_reopened_and_the_sound_returns() {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let ctx = egui::Context::default();
let mut app = FilmcraftApp::new(session);
let starts = Arc::new(AtomicUsize::new(0));
let ticking = Arc::new(AtomicBool::new(true));
app.audio = Some(Box::new(Reviving { starts: starts.clone(), opens: Arc::new(AtomicBool::new(true)), ticking: ticking.clone() }));
app.play(1.0);
app.end_preroll(0.0);
assert!(app.playback.audio_clock && starts.load(Ordering::Relaxed) == 1);
// the stream dies: the device stops reporting a playback clock
ticking.store(false, Ordering::Relaxed);
let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx()));
output.textures_delta.clear();
assert!(!app.playback.audio_clock);
// the device recovers before the next retry: playback reopens it itself
ticking.store(true, Ordering::Relaxed);
let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0 + AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx()));
output.textures_delta.clear();
assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is reopened without pause/play");
assert!(app.playback.audio_clock, "the fresh stream is the master clock again");
app.stop();
}
#[test]
fn a_device_that_appears_mid_playback_starts_sounding() {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let ctx = egui::Context::default();
let mut app = FilmcraftApp::new(session);
let starts = Arc::new(AtomicUsize::new(0));
let opens = Arc::new(AtomicBool::new(false));
app.audio = Some(Box::new(Reviving { starts: starts.clone(), opens: opens.clone(), ticking: Arc::new(AtomicBool::new(true)) }));
app.play(1.0);
app.end_preroll(0.0);
assert!(!app.playback.audio_clock, "no device yet: playing on the wall clock");
// headphones plug in while playing: the next retry opens them
opens.store(true, Ordering::Relaxed);
let mut output = ctx.run_ui(egui::RawInput { time: Some(AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx()));
output.textures_delta.clear();
assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is retried without pause/play");
assert!(app.playback.audio_clock);
app.stop();
}
}
/// Whether a shortcut chord must be left alone because a text field is focused.
+28 -7
View File
@@ -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;
}
+20
View File
@@ -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()));
+19
View File
@@ -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");
}
+3
View File
@@ -11,3 +11,6 @@ workspace = true
[dependencies]
thiserror = { workspace = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+158
View File
@@ -0,0 +1,158 @@
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
//! ffmpeg-generated VP8 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use filmcraft_vp8::Decoder;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
/// (name, encoder args, pix_fmt)
const SEEDS: &[(&str, &str, &str)] = &[
("vp8_fz_default", "-c:v libvpx -g 4 -b:v 300k", "yuv420p"),
("vp8_fz_partitions", "-c:v libvpx -g 6 -b:v 300k -slices 8 -auto-alt-ref 0 -lag-in-frames 0 -error-resilient 1", "yuv420p"),
];
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let Some(h) = data.get(6..8) else { return out };
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
while let Some(hd) = data.get(pos..pos + 12) {
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
pos += 12;
let end = pos.saturating_add(size).min(data.len());
out.push(data[pos..end].to_vec());
pos = end;
}
out
}
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
let out = filmcraft_testkit::fixtures_dir("vp8").join(format!("{name}.ivf"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
.args(a)
.args(["-f", "ivf"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
Some(ivf_frames(&std::fs::read(p).ok()?))
}
static PANICS: AtomicUsize = AtomicUsize::new(0);
/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test.
fn count_panics() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::panic::set_hook(Box::new(|info| {
PANICS.fetch_add(1, Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(frames: &[Vec<u8>], label: &str) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for _ in 0..1 {
let mut dec = Decoder::new();
for (i, f) in frames.iter().enumerate() {
let _ = dec.decode(f, i as i64);
}
}
}));
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP8 fuzz") else { return };
for &(name, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx") {
filmcraft_testkit::skip("VP8 fuzz", &format!("this ffmpeg has no libvpx encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, args, pix) else {
panic!("ffmpeg has libvpx but could not encode the {name} seed");
};
if seed.is_empty() {
filmcraft_testkit::skip("VP8 fuzz", &format!("the {name} seed has no frames"));
continue;
}
// truncate each of the first frames at every length
for fi in 0..seed.len().min(3) {
let len = seed[fi].len();
for n in (0..len).step_by((len / 200).max(1)) {
let mut d = seed.clone();
d[fi].truncate(n);
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
}
}
// header region of every frame: every byte to a few values, every bit
for fi in 0..seed.len() {
for p in 0..seed[fi].len().min(96) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[fi][p] = v;
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[fi][p] ^= 1 << b;
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
}
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let fi = rng.below(d.len());
if d[fi].is_empty() {
continue;
}
let p = rng.below(d[fi].len());
if rng.below(2) == 0 {
d[fi][p] ^= 1 << rng.below(8);
} else {
d[fi][p] = rng.next() as u8;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
+11 -1
View File
@@ -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")
+1
View File
@@ -19,3 +19,4 @@ thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+162
View File
@@ -0,0 +1,162 @@
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
//! ffmpeg-generated VP9 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use filmcraft_vp9::Decoder;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
/// (name, encoder args, pix_fmt)
const SEEDS: &[(&str, &str, &str)] = &[
("vp9_fz_default", "-c:v libvpx-vp9 -g 4 -b:v 300k -lag-in-frames 0", "yuv420p"),
// libvpx needs 256 px per tile column, so two columns need 512 px
("vp9_fz_2tiles_10bit", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -tile-columns 1 -profile:v 2 -s 512x48", "yuv420p10le"),
("vp9_fz_444", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -profile:v 1", "yuv444p"),
("vp9_fz_altref", "-c:v libvpx-vp9 -g 6 -b:v 300k -auto-alt-ref 1 -lag-in-frames 5", "yuv420p"),
];
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let Some(h) = data.get(6..8) else { return out };
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
while let Some(hd) = data.get(pos..pos + 12) {
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
pos += 12;
let end = pos.saturating_add(size).min(data.len());
out.push(data[pos..end].to_vec());
pos = end;
}
out
}
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
let out = filmcraft_testkit::fixtures_dir("vp9").join(format!("{name}.ivf"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
.args(a)
.args(["-f", "ivf"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
Some(ivf_frames(&std::fs::read(p).ok()?))
}
static PANICS: AtomicUsize = AtomicUsize::new(0);
/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test.
fn count_panics() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::panic::set_hook(Box::new(|info| {
PANICS.fetch_add(1, Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(frames: &[Vec<u8>], label: &str) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for threads in [1, 3] {
let mut dec = Decoder::with_threads(threads);
for (i, f) in frames.iter().enumerate() {
let _ = dec.decode(f, i as i64);
}
let _ = dec.flush();
}
}));
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP9 fuzz") else { return };
for &(name, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx-vp9") {
filmcraft_testkit::skip("VP9 fuzz", &format!("this ffmpeg has no libvpx-vp9 encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, args, pix) else {
panic!("ffmpeg has libvpx-vp9 but could not encode the {name} seed");
};
if seed.is_empty() {
filmcraft_testkit::skip("VP9 fuzz", &format!("the {name} seed has no frames"));
continue;
}
// truncate each of the first frames at every length
for fi in 0..seed.len().min(3) {
let len = seed[fi].len();
for n in (0..len).step_by((len / 200).max(1)) {
let mut d = seed.clone();
d[fi].truncate(n);
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
}
}
// header region of every frame: every byte to a few values, every bit
for fi in 0..seed.len() {
for p in 0..seed[fi].len().min(96) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[fi][p] = v;
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[fi][p] ^= 1 << b;
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
}
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let fi = rng.below(d.len());
if d[fi].is_empty() {
continue;
}
let p = rng.below(d[fi].len());
if rng.below(2) == 0 {
d[fi][p] ^= 1 << rng.below(8);
} else {
d[fi][p] = rng.next() as u8;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
+11 -1
View File
@@ -12,8 +12,18 @@ element count checked against the declared dimensions.
"""
import re
import sys
from pathlib import Path
text = open(sys.argv[1], encoding="utf-8").read().replace("−", "-").replace("–", "-")
def _spec_path(arg):
"""Resolve a CLI path argument and require it to be an existing regular file."""
path = Path(arg).resolve()
if not path.is_file():
sys.exit(f"not a readable file: {arg}")
return path
text = open(_spec_path(sys.argv[1]), encoding="utf-8").read().replace("−", "-").replace("–", "-")
lines = [l for l in text.split("\n") if "Copyright" not in l and "VP9 Bitstream & Decoding Process Specification" not in l]
text = "\n".join(lines)
+15 -12
View File
@@ -23,6 +23,7 @@ and write wherever the user can.
```sh
cargo build --release -p filmcraft-cli -p filmcraft
export FILMCRAFT_CONTROL_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
./target/release/filmcraft --control 9876 & # only for the bridge server
claude # approve the project MCP servers when asked
```
@@ -134,29 +135,31 @@ progress and stop it:
## 2. Control channel
`filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT=9876`) listens on `127.0.0.1` only. Send one
JSON request per line and get one JSON reply per line. Full method table:
JSON request per line and get one JSON reply per line. Set a fresh `FILMCRAFT_CONTROL_TOKEN` for the
desktop session as above; the CLI / MCP bridge must inherit the same variable. Raw requests must
include it as `token` on every line (replace the placeholders below). Full setup and method table:
[control-protocol.md](control-protocol.md).
```jsonc
{"id":1,"method":"engine.commands"}
{"id":2,"method":"engine.execute","params":{"command":"file.openDemoProject"}}
{"id":3,"method":"engine.execute","params":{"command":"playhead.set","params":{"seconds":2}}}
{"id":4,"method":"engine.execute","params":{"command":"sequence.addEdit"}}
{"id":5,"method":"engine.execute","params":{"command":"effects.apply","params":{"effect":"Gaussian Blur"}}}
{"id":6,"method":"ui.elements","params":{"prefix":"tools."}}
{"id":7,"method":"ui.click","params":{"id":"tools.Razor"}}
{"id":8,"method":"ui.key","params":{"key":"Cmd+Z"}}
{"id":9,"method":"ui.screenshot","params":{"path":"/tmp/timeline.png","panel":"Timeline"}}
{"id":1,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.commands"}
{"id":2,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.execute","params":{"command":"file.openDemoProject"}}
{"id":3,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.execute","params":{"command":"playhead.set","params":{"seconds":2}}}
{"id":4,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.execute","params":{"command":"sequence.addEdit"}}
{"id":5,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"engine.execute","params":{"command":"effects.apply","params":{"effect":"Gaussian Blur"}}}
{"id":6,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"ui.elements","params":{"prefix":"tools."}}
{"id":7,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"ui.click","params":{"id":"tools.Razor"}}
{"id":8,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"ui.key","params":{"key":"Cmd+Z"}}
{"id":9,"token":"<FILMCRAFT_CONTROL_TOKEN>","method":"ui.screenshot","params":{"path":"/tmp/timeline.png","panel":"Timeline"}}
```
Replies look like `{"id":4,"ok":true,"result":{"cuts":2}}` or `{"id":7,"ok":false,"error":"…"}`.
Minimal client:
```python
import json, socket
import json, os, socket
s = socket.create_connection(("127.0.0.1", 9876)); f = s.makefile("rw")
def call(method, **params):
f.write(json.dumps({"id": 1, "method": method, "params": params}) + "\n"); f.flush()
f.write(json.dumps({"id": 1, "token": os.environ["FILMCRAFT_CONTROL_TOKEN"], "method": method, "params": params}) + "\n"); f.flush()
return json.loads(f.readline())
print(call("engine.execute", command="sequence.inspect"))
```
+1
View File
@@ -39,6 +39,7 @@ cargo xtask web --serve 8765 # the web app on http://12
| Variable | Effect |
|---|---|
| `FILMCRAFT_CONTROL_PORT` | Same as `--control <port>` |
| `FILMCRAFT_CONTROL_TOKEN` | Required for `--control`: 64 random hex characters, inherited by authorized clients; [setup](control-protocol.md) |
| `FILMCRAFT_CPU_COMPOSITE=1` | Disables the GPU compositor |
| `FILMCRAFT_DEV_DESKTOP_ENTRY=1` | Linux, opt-in: a run from a source tree installs a user-level desktop entry (and the icons from `assets/app-icon`) so the taskbar shows the FilmCraft icon. Nothing is written without it; packaged runs never write one |
| `FILMCRAFT_DATA_DIR` | Same as `--data-dir <dir>`: auto-save, crash recovery, settings and logs |
+19 -3
View File
@@ -2,6 +2,22 @@
## Desktop control channel
`filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT`) listens on `127.0.0.1:<port>` (loopback only).
The listener requires `FILMCRAFT_CONTROL_TOKEN`: 64 random hex characters shared with authorized
clients through their inherited environment. Generate a fresh token for each desktop session, then
start the desktop and bridge from that environment:
```sh
export FILMCRAFT_CONTROL_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
filmcraft --control 9876 &
filmcraft-cli mcp --bridge 127.0.0.1:9876
```
The CLI / MCP bridge reads this variable automatically. Keep it private: don't put the value in
command-line arguments, logs, checked-in MCP configuration, or shared files. Without a valid value,
the desktop leaves the listener closed. Every raw JSON request must include the same top-level
`token` string; missing or incorrect credentials get an error and close the connection before any
request is dispatched. Loopback alone does not restrict clients to the desktop owner's OS account.
One JSON request per line → one JSON reply per line. The app opts out of macOS App Nap
(`apps/filmcraft/src/app_nap.rs`): a hidden window would otherwise drop the whole process to
background priority, and on a busy machine it would stop answering.
@@ -13,11 +29,11 @@ nothing sent after it on that connection runs. That covers text that isn't JSON,
an object without `method`, invalid UTF-8, and a line longer than 4 MiB. An HTTP request (for example a
web page's cross-origin `fetch` to `127.0.0.1:<port>`) therefore can't smuggle a command in its body:
its request line is rejected first. At most 16 connections are served at once; further ones get an error
line and are closed. Clients that get an error reply should reconnect. The port has no authentication,
so only enable it while you use it. Transport: `apps/filmcraft/src/control_server.rs`.
line and are closed. Clients that get an error reply should reconnect. Transport:
`apps/filmcraft/src/control_server.rs`.
```json
{"id": 1, "method": "engine.execute", "params": {"command": "sequence.addEdit", "params": {"seconds": 3}}}
{"id": 1, "token": "<FILMCRAFT_CONTROL_TOKEN>", "method": "engine.execute", "params": {"command": "sequence.addEdit", "params": {"seconds": 3}}}
{"id": 1, "ok": true, "result": {"cuts": 2}}
```
+3 -2
View File
@@ -125,9 +125,10 @@ like Premiere's:
| Update it at least every N second(s) | `autoSave.recoveryIntervalSeconds` | 5 (1–600) |
At each interval, if the project changed since the last auto-save, a copy is written to the
`Auto-Save` folder next to the project as `<name>-YYYY-MM-DD_HH-MM-SS.fcproj` (local time). An
`Auto-Save` folder next to the project as `<name>-YYYY-MM-DD_HH-MM-SS-<unix seconds>.fcproj` (local time, then the real time that orders
the copies, so a clock change at the end of daylight saving time cannot reorder them). An
unsaved project uses `<data dir>/Auto-Save/`. After each write the oldest copies beyond the limit are
deleted. Only files named exactly `<name>-<timestamp>.fcproj` are ever deleted. An auto-save is an
deleted. Only files named exactly `<name>-<timestamp>.fcproj` or `<name>-<timestamp>-<unix seconds>.fcproj` are ever deleted. An auto-save is an
ordinary project file: open it with File ▸ Open Project. `file.listAutoSaves` lists them, newest
first.
+2 -2
View File
@@ -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 |
+1 -1
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
sonar.projectKey=storytold_filmcraft
sonar.organization=storytold
sonar.coverage.exclusions=**/tools/**,**/docs/**,**/examples/**