From 689e37782a890fc76a0e0daa671ab169771a6c38 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:29:27 -0400 Subject: [PATCH 001/110] fix: harden CLI file I/O and AV1 tooling (SonarCloud) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read and write CLI inputs/outputs on a blocking pool instead of stalling the async runtime (rust:S7493). - Document the FLAC STREAMINFO MD5 as the RFC 9639 integrity signature it is, and validate the AV1 generator's spec path before reading it. - Normalize the DCT basis literals that read as numeric suffixes. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- apps/filmcraft-cli/src/main.rs | 25 ++++++++++++++++++++++--- crates/av1/tools/extract_tables.py | 14 +++++++++----- crates/dnx/src/tables.rs | 8 ++++---- crates/flac/src/lib.rs | 4 +++- crates/format/src/atomic.rs | 5 +++-- crates/prores/src/tables.rs | 8 ++++---- 6 files changed, 45 insertions(+), 19 deletions(-) diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 8b508e334..cfc04fc3b 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -137,6 +137,25 @@ fn usage(msg: impl std::fmt::Display) -> ! { std::process::exit(2) } +/// Read a file off the runtime thread: `cli` is async because MCP, bridge calls and control +/// commands await, so a blocking `std::fs` read would stall every other task on that thread. +async fn read_file(path: impl Into) -> std::io::Result> { + let path = path.into(); + tokio::task::spawn_blocking(move || std::fs::read(path)).await.map_err(std::io::Error::other)? +} + +/// `read_file` for UTF-8 text. +async fn read_text(path: impl Into) -> std::io::Result { + let path = path.into(); + tokio::task::spawn_blocking(move || std::fs::read_to_string(path)).await.map_err(std::io::Error::other)? +} + +/// Write a file off the runtime thread; see `read_file`. +async fn write_file(path: impl Into, data: Vec) -> std::io::Result<()> { + let path = path.into(); + tokio::task::spawn_blocking(move || std::fs::write(path, data)).await.map_err(std::io::Error::other)? +} + fn fail(msg: impl std::fmt::Display) -> ! { diag(format_args!("filmcraft-cli: {msg}")); std::process::exit(1) @@ -307,7 +326,7 @@ async fn cli() { "bench-decode" => { let path = a.pos(1).unwrap_or_else(|| usage("bench-decode ")); let n: i64 = a.opt("--frames").and_then(|v| v.parse().ok()).unwrap_or(120); - let src = filmcraft_codecs::open_bytes(path, std::fs::read(path).unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e)); + let src = filmcraft_codecs::open_bytes(path, read_file(path).await.unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e)); let rate = src.info().frame_rate(); let t0 = std::time::Instant::now(); for f in 0..n { @@ -363,7 +382,7 @@ async fn cli() { let text = if script == "-" { std::io::read_to_string(std::io::stdin()).unwrap_or_else(|e| fail(e)) } else { - std::fs::read_to_string(script).unwrap_or_else(|e| fail(format!("{script}: {e}"))) + read_text(script).await.unwrap_or_else(|e| fail(format!("{script}: {e}"))) }; let mut b = Backend::open(&a); let mut failed = 0; @@ -487,7 +506,7 @@ async fn cli() { let (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); let dt = t0.elapsed(); let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e)); - std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}"))); + write_file(out, png).await.unwrap_or_else(|e| fail(format!("{out}: {e}"))); let offline: Vec = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect(); check_offline(&a, &offline, &format!("render: {out}")); diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); diff --git a/crates/av1/tools/extract_tables.py b/crates/av1/tools/extract_tables.py index 5ee6d95cf..e64a931f1 100644 --- a/crates/av1/tools/extract_tables.py +++ b/crates/av1/tools/extract_tables.py @@ -20,15 +20,19 @@ import sys from pathlib import Path -def _spec_path(arg): - """Resolve a CLI path argument and require it to be an existing regular file.""" +def _spec_dir(arg): + """Resolve the CLI argument and require it to be an existing directory.""" path = Path(arg).resolve() - if not path.is_file(): - sys.exit(f"not a readable file: {arg}") + if not path.is_dir(): + sys.exit(f"not a directory: {arg}") return path -root = sys.argv[1] +if len(sys.argv) != 2: + sys.exit("usage: extract_tables.py ") +# The argument is the operator's own spec checkout; _spec_dir requires it to be an existing +# directory before anything is read from it. +root = _spec_dir(sys.argv[1]) # NOSONAR files = sorted(f for f in os.listdir(root) if f.endswith(".md")) texts = {f: open(os.path.join(root, f), encoding="utf-8").read() for f in files} diff --git a/crates/dnx/src/tables.rs b/crates/dnx/src/tables.rs index c1fe24909..905d808f1 100644 --- a/crates/dnx/src/tables.rs +++ b/crates/dnx/src/tables.rs @@ -226,13 +226,13 @@ pub(crate) fn vlc(i: usize) -> &'static VlcSet { /// Orthonormal 1-D DCT-II basis: `BASIS[u][x] = C(u)/2 · cos((2x+1)uπ/16)`, `C(0) = 1/√2`. pub(crate) const BASIS: [[f32; 8]; 8] = [ [0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39], - [0.490_392_64, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.490_392_64], + [0.49039264, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.49039264], [0.461_939_77, 0.191_341_72, -0.191_341_72, -0.461_939_77, -0.461_939_77, -0.191_341_72, 0.191_341_72, 0.461_939_77], - [0.415_734_8, -0.097_545_16, -0.490_392_64, -0.277_785_12, 0.277_785_12, 0.490_392_64, 0.097_545_16, -0.415_734_8], + [0.415_734_8, -0.097_545_16, -0.49039264, -0.277_785_12, 0.277_785_12, 0.49039264, 0.097_545_16, -0.415_734_8], [0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39, 0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39], - [0.277_785_12, -0.490_392_64, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.490_392_64, -0.277_785_12], + [0.277_785_12, -0.49039264, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.49039264, -0.277_785_12], [0.191_341_72, -0.461_939_77, 0.461_939_77, -0.191_341_72, -0.191_341_72, 0.461_939_77, -0.461_939_77, 0.191_341_72], - [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.490_392_64, 0.490_392_64, -0.415_734_8, 0.277_785_12, -0.097_545_16], + [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.49039264, 0.49039264, -0.415_734_8, 0.277_785_12, -0.097_545_16], ]; #[cfg(test)] diff --git a/crates/flac/src/lib.rs b/crates/flac/src/lib.rs index 0844b7c3c..10d8d28fa 100644 --- a/crates/flac/src/lib.rs +++ b/crates/flac/src/lib.rs @@ -111,7 +111,9 @@ impl Encoder { total_samples: 0, min_frame: 0, max_frame: 0, - md5: md5::Md5::default(), + // RFC 9639 requires an MD5 signature of the unencoded audio in STREAMINFO; this is an + // integrity checksum, not a password hash or a security boundary. + md5: md5::Md5::default(), // NOSONAR }) } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 086333cda..1e4d06a6a 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -65,8 +65,9 @@ fn atomic_write_new_with(path: &Path, write: impl FnOnce(&mut File) -> io::Resul #[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)? + // Like an ordinary file creation: request 0666 and let the process umask decide the + // effective mode (0644 with the usual 022, 0664 with 002). + tempfile::Builder::new().permissions(fs::Permissions::from_mode(0o666)).tempfile_in(&dir)? // NOSONAR }; #[cfg(not(unix))] let mut staged = tempfile::NamedTempFile::new_in(&dir)?; diff --git a/crates/prores/src/tables.rs b/crates/prores/src/tables.rs index a395e906f..975e9d3f5 100644 --- a/crates/prores/src/tables.rs +++ b/crates/prores/src/tables.rs @@ -88,13 +88,13 @@ pub(crate) fn qscale(qidx: u8) -> u32 { /// Orthonormal 1-D DCT-II basis: `BASIS[u][x] = C(u)/2 · cos((2x+1)uπ/16)`, `C(0) = 1/√2`. pub(crate) const BASIS: [[f32; 8]; 8] = [ [0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39], - [0.490_392_64, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.490_392_64], + [0.49039264, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.49039264], [0.461_939_77, 0.191_341_72, -0.191_341_72, -0.461_939_77, -0.461_939_77, -0.191_341_72, 0.191_341_72, 0.461_939_77], - [0.415_734_8, -0.097_545_16, -0.490_392_64, -0.277_785_12, 0.277_785_12, 0.490_392_64, 0.097_545_16, -0.415_734_8], + [0.415_734_8, -0.097_545_16, -0.49039264, -0.277_785_12, 0.277_785_12, 0.49039264, 0.097_545_16, -0.415_734_8], [0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39, 0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39], - [0.277_785_12, -0.490_392_64, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.490_392_64, -0.277_785_12], + [0.277_785_12, -0.49039264, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.49039264, -0.277_785_12], [0.191_341_72, -0.461_939_77, 0.461_939_77, -0.191_341_72, -0.191_341_72, 0.461_939_77, -0.461_939_77, 0.191_341_72], - [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.490_392_64, 0.490_392_64, -0.415_734_8, 0.277_785_12, -0.097_545_16], + [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.49039264, 0.49039264, -0.415_734_8, 0.277_785_12, -0.097_545_16], ]; #[cfg(test)] From b366ff320555484a8977d771521223b2521cdbad Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 23 Oct 2026 08:51:23 -0600 Subject: [PATCH 002/110] Regression tests for #130, #703, #1084 and #1309 (#2861) * #703: test that timeline track lines are visible and the playhead head is short The lines between timeline tracks were drawn in the timeline background colour, so they could not be seen, nothing marked the top of the highest video track, and the playhead head covered the whole ruler. The tests check that separator-coloured lines run under every track and above the top video track, and that the head stays within the bottom 14 points of the ruler. Without the fix no such lines are drawn and the head is 38 points tall. * #130: test that timecodes and menus use the Premiere-style type The big timecodes were 15 pt semibold and the menu bar's menus used egui's default text size. The tests check that the timeline, program and reference timecodes are painted in 16 pt regular in the timecode colour, and that File menu items are drawn at 13 pt. Without the fix the timecodes are 15 pt semibold and the menu items are 12 pt. * #1084: test that the Import captions file button label is legible in light mode The label was always white, so it disappeared against the light grey button in light mode. The test shows the empty Captions tab in the dark and light themes and requires at least 3:1 contrast between the label and the button fill. Without the fix the light theme fails at 1.04:1. * #1309: test that the sort arrow does not cover a sorted column's name In a narrow List-view column the sort arrow was painted over the end of the clipped column name. The test narrows the sorted Name column and requires the name's visible clip rect to end left of the arrow. Without the fix the name stays visible up to x=67 while the arrow starts at x=60. * #1084: hold the Import captions label to 4.5:1 contrast The label is normal-size text (12 pt semibold), so WCAG AA asks for 4.5:1, not the 3:1 the test allowed. Both themes clear 4.5:1 today; with the fix reverted the label is at 1.04:1. * #130: menu bar titles use the 13 pt menu text, like their menus #130 styled the menus' items with theme::menu_style through MenuConfig, which egui applies to submenus only. The bar's own titles kept egui's default 12 pt, dimmer button text. MenuBar::style takes the same style, so the titles and their menus now share one size and colour, as in a native menu bar. The #130 test now checks the File title too; without this change it fails at 12 pt. --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/tests/captions_font_ui.rs | 49 ++++++++++++ crates/ui-egui/tests/panels_ui.rs | 71 ++++++++++++++++ crates/ui-egui/tests/project_panel_ui.rs | 53 ++++++++++++ .../ui-egui/tests/timeline_track_height_ui.rs | 80 +++++++++++++++++++ 5 files changed, 255 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1d760d0c0..d4742ff59 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -904,7 +904,8 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let labels = app.session.prefs.labels.clone(); let ctx = ui.ctx().clone(); let mut clicked: Option = None; - egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| { + // The bar's titles and their menus share one size and colour, as in a native menu bar. + egui::MenuBar::new().style(crate::theme::menu_style).config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| { let mut titles = Vec::with_capacity(MENUS.len()); for top in MENUS { let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); diff --git a/crates/ui-egui/tests/captions_font_ui.rs b/crates/ui-egui/tests/captions_font_ui.rs index bd6822d55..877976c74 100644 --- a/crates/ui-egui/tests/captions_font_ui.rs +++ b/crates/ui-egui/tests/captions_font_ui.rs @@ -93,3 +93,52 @@ fn caption_font_picker_sets_the_track_font() { d.frames(3); assert_eq!(d.font(track), "Noto Serif"); } + +/// Every text shape on screen, with the colour of its first run. +fn painted_text(d: &Driver) -> Vec<(String, egui::Color32)> { + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Color32)>) { + match s { + egui::Shape::Text(t) => { + if let Some(sec) = t.galley.job.sections.first() { + out.push((t.galley.text().to_string(), sec.format.color)); + } + } + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + d.harness.output().shapes.iter().for_each(|c| walk(&c.shape, &mut out)); + out +} + +/// Relative luminance (WCAG) of an opaque colour. +fn luminance(c: egui::Color32) -> f32 { + let f = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * f(c.r()) + 0.7152 * f(c.g()) + 0.0722 * f(c.b()) +} + +// Regression test for #1072: the Captions tab's "Import captions file…" button label was always +// white, so it vanished against the light grey button in light mode. +#[test] +fn import_captions_button_label_is_legible_in_both_themes() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Captions and Graphics"})); + d.ok("ui.click", json!({"id": "text.tab.Captions"})); + for theme in ["dark", "light"] { + d.ok("ui.set", json!({"theme": theme})); + d.frames(3); + assert!(d.ids("text.captions.import").iter().any(|i| i == "text.captions.import"), "{theme}: the empty Captions tab offers the import button"); + let field_bg = d.harness.state().tokens.field_bg; + let (_, color) = painted_text(&d) + .into_iter() + .find(|(text, _)| text.starts_with("Import captions file")) + .unwrap_or_else(|| panic!("{theme}: the button label is not drawn")); + let (hi, lo) = (luminance(color).max(luminance(field_bg)), luminance(color).min(luminance(field_bg))); + let contrast = (hi + 0.05) / (lo + 0.05); + assert!(contrast >= 4.5, "{theme}: label {color:?} on {field_bg:?} has contrast {contrast:.2}:1"); + } +} diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/panels_ui.rs index 483bee1c9..150f4d0bf 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/panels_ui.rs @@ -781,3 +781,74 @@ fn effects_are_indented_under_their_folders() { assert!(b >= a + 12.0, "`{inner}` ({b}) is not indented under `{outer}` ({a})"); } } + +/// The font of the first run of every text shape drawn this frame, with the text, position and colour. +fn painted_text(d: &Driver) -> Vec<(String, egui::Pos2, egui::FontId, egui::Color32)> { + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2, egui::FontId, egui::Color32)>) { + match s { + egui::Shape::Text(t) => { + if let Some(sec) = t.galley.job.sections.first() { + out.push((t.galley.text().to_string(), t.pos, sec.format.font_id.clone(), sec.format.color)); + } + } + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + d.harness.output().shapes.iter().for_each(|c| walk(&c.shape, &mut out)); + out +} + +// Regression test for #130: the big blue timecodes over the timeline and under the monitors were +// drawn in 15 pt semibold; Premiere's are regular weight, 16 pt, in the timecode colour. +#[test] +fn big_timecodes_are_regular_weight_16pt_in_the_timecode_colour() { + let mut d = Driver::demo(); + d.ok("ui.panel.show", json!({"panel": "Program"})); + d.ok("ui.panel.show", json!({"panel": "ReferenceMonitor"})); + d.frames(3); + let timecode = d.app().tokens.timecode; + for id in ["timeline.timecode", "program.timecode", "reference.timecode"] { + let rect = { + let v = d.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r = &e["rect"]; + egui::Rect::from_min_size( + egui::pos2(r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32), + egui::vec2(r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32), + ) + }; + let shown = d.label(id); + let (_, _, font, color) = painted_text(&d) + .into_iter() + .find(|(text, pos, ..)| *text == shown && rect.expand(2.0).contains(*pos)) + .unwrap_or_else(|| panic!("{id}: `{shown}` is not drawn in {rect:?}")); + assert_eq!(font, egui::FontId::new(16.0, egui::FontFamily::Proportional), "{id}: font"); + assert_eq!(color, timecode, "{id}: colour"); + } +} + +// Regression test for #130: the menu bar's menus were drawn in egui's default body size; Premiere's +// are the system's, larger (13 pt) and brighter than panel text. +#[test] +fn menu_bar_menus_use_the_13pt_menu_text() { + let mut d = Driver::demo(); + d.click("menu.File"); + d.frames(3); + for id in ["menu.File", "menu.file.open", "menu.file.save", "menu.file.saveAs"] { + let v = d.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no menu item {id}")).clone(); + let label = e["label"].as_str().unwrap().to_string(); + let r = &e["rect"]; + let rect = egui::Rect::from_min_size( + egui::pos2(r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32), + egui::vec2(r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32), + ); + let (_, _, font, _) = painted_text(&d) + .into_iter() + .find(|(text, pos, ..)| *text == label && rect.contains(*pos)) + .unwrap_or_else(|| panic!("`{label}` is not drawn in {rect:?}")); + assert_eq!(font.size, 13.0, "`{label}` in the File menu"); + } +} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index 87874210f..8fdbb9f0c 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -235,6 +235,59 @@ fn list_view_columns_sort_resize_and_rename() { assert_ne!(d.app().session.project.item(ItemId(items[0])).unwrap().name, "Hero shot"); } +// Regression test for #1295: in a narrow List-view column the sort arrow was painted over the end of +// the (clipped) column name; the name must be clipped to the left of the arrow. +#[test] +fn sorted_column_header_name_is_clipped_clear_of_the_sort_arrow() { + fn collect<'a>(shapes: &'a [egui::Shape], clip: egui::Rect, out: &mut Vec<(&'a egui::Shape, egui::Rect)>) { + for s in shapes { + match s { + egui::Shape::Vec(v) => collect(v, clip, out), + other => out.push((other, clip)), + } + } + } + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + d.frames(2); + // the default sort is by Name; make that column narrow enough that its name is clipped + for c in &mut d.app().session.prefs.project_panel.view.columns { + if c.name == "Name" { + c.width = 40.0; + } + } + d.frames(3); + assert_eq!(d.app().session.prefs.project_panel.view.sort.column, "Name"); + let [hx, hy, hw, hh] = d.rect("project.list.header.Name"); + let header = egui::Rect::from_min_size(egui::pos2(hx, hy), egui::vec2(hw, hh)); + let mut all = Vec::new(); + let clipped: Vec<_> = d.harness.output().shapes.iter().map(|c| (c.shape.clone(), c.clip_rect)).collect(); + for (shape, clip) in &clipped { + collect(std::slice::from_ref(shape), *clip, &mut all); + } + // the visible part of the name: the clip rect of the header's "Name" text + let name_clip = all + .iter() + .find_map(|(s, clip)| match s { + egui::Shape::Text(t) if t.galley.text() == "Name" && header.contains(t.pos) => Some(*clip), + _ => None, + }) + .expect("the Name header text is drawn"); + // the arrow: the 1.3 px strokes drawn inside the header + let arrow_x: Vec = all + .iter() + .filter_map(|(s, _)| match s { + egui::Shape::LineSegment { points, stroke } if (stroke.width - 1.3).abs() < 1e-3 && header.contains(points[0]) && header.contains(points[1]) => { + Some(points[0].x.min(points[1].x)) + } + _ => None, + }) + .collect(); + assert!(!arrow_x.is_empty(), "the sort arrow is drawn in the sorted column's header"); + let arrow_left = arrow_x.into_iter().fold(f32::MAX, f32::min); + assert!(name_clip.max.x <= arrow_left, "the name is visible up to x={} but the sort arrow starts at x={arrow_left}", name_clip.max.x); +} + #[test] fn icon_view_hover_scrub_sets_in_and_out() { let mut d = project_driver(); diff --git a/crates/ui-egui/tests/timeline_track_height_ui.rs b/crates/ui-egui/tests/timeline_track_height_ui.rs index eb272be4b..53e54c6c9 100644 --- a/crates/ui-egui/tests/timeline_track_height_ui.rs +++ b/crates/ui-egui/tests/timeline_track_height_ui.rs @@ -112,3 +112,83 @@ fn the_bottom_edge_of_the_last_audio_track_resizes_the_audio_tracks_from_either_ d.drag(&id, 0.1, -15.0); assert!(d.heights().1 < a1 - 5.0); } + +/// Every shape drawn this frame, flattened. +fn flat_shapes(d: &Driver) -> Vec { + fn walk(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + other => out.push(other.clone()), + } + } + let mut out = Vec::new(); + d.harness.output().shapes.iter().for_each(|c| walk(&c.shape, &mut out)); + out +} + +fn rect_of(d: &mut Driver, id: &str) -> egui::Rect { + let v = d.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|n| n.as_f64().unwrap() as f32).collect(); + egui::Rect::from_min_size(egui::pos2(r[0], r[1]), egui::vec2(r[2], r[3])) +} + +// Regression test for #703: the lines between the timeline's tracks were drawn in the timeline's own +// background colour, so they were invisible, and nothing marked the top of the highest video track. +#[test] +fn timeline_draws_visible_lines_between_tracks_and_above_the_top_video_track() { + let mut d = Driver::demo(); + let (top, _) = d.labels(); + let separator = d.app().tokens.separator; + let ruler = rect_of(&mut d, "timeline.ruler"); + let top_edge = rect_of(&mut d, &format!("timeline.track.{top}.resizeTop")); + let seq = d.app().session.active_sequence().unwrap(); + let tracks = seq.video_tracks.len() + seq.audio_tracks.len(); + // horizontal separator lines across the track area (the headers' own lines start left of the ruler) + let mut ys: Vec = flat_shapes(&d) + .into_iter() + .filter_map(|s| match s { + egui::Shape::LineSegment { points, stroke } + if stroke.color == separator + && points[0].y == points[1].y + && points[0].x >= ruler.min.x - 1.0 + && points[1].x - points[0].x > 200.0 + && points[0].y > ruler.max.y => + { + Some(points[0].y) + } + _ => None, + }) + .collect(); + ys.sort_by(f32::total_cmp); + ys.dedup_by(|a, b| (*a - *b).abs() < 0.1); + assert!(ys.len() > tracks, "a line under each of the {tracks} tracks and one above the top video track, found {ys:?}"); + let first = ys[0]; + assert!((first - top_edge.center().y).abs() < 1.5, "the first line ({first}) is at the top edge of the highest video track ({})", top_edge.center().y); +} + +// Regression test for #703: the playhead's head spanned the whole ruler and covered the markers and +// the timecode labels; it must be a short head on the tick row at the bottom of the ruler. +#[test] +fn playhead_head_is_short_and_sits_on_the_ruler_tick_row() { + let mut d = Driver::demo(); + let playhead = d.app().tokens.playhead; + let ruler = rect_of(&mut d, "timeline.ruler"); + let heads: Vec = flat_shapes(&d) + .into_iter() + .filter_map(|s| match s { + egui::Shape::Path(p) if p.fill == playhead && p.points.len() == 5 => Some(egui::Rect::from_points(&p.points)), + _ => None, + }) + .filter(|r| r.max.y <= ruler.max.y + 1.0 && r.min.y >= ruler.min.y) + .collect(); + assert_eq!(heads.len(), 1, "one playhead head over the ruler: {heads:?}"); + let head = heads[0]; + assert!(head.height() <= 14.0, "the head is {} tall, ruler is {}", head.height(), ruler.height()); + assert!( + head.min.y >= ruler.max.y - 14.0, + "the head starts at y={}, leaving the ruler's marker and label rows (above y={}) clear", + head.min.y, + ruler.max.y - 14.0 + ); +} From 700bdd9190c03fa80b587aa0d792035ab1c901e1 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:51:34 +0900 Subject: [PATCH 003/110] Scroll the timeline clip menu when it is taller than the screen (#2852) (#2862) * Scroll the timeline clip menu when it is taller than the screen (#2852) The clip menu has about 35 items and ran off the bottom of short screens with no way to reach the rest. It now scrolls inside the window, as the panel menu already does. Co-Authored-By: Claude Opus 5.5 (1M context) * Let the clip menu move up before it scrolls The scroll area only took the height left below the pointer, so the menu stayed where it was opened and scrolled even when the whole menu fit on the screen, and the clip menu tests in clip_dialogs_ui could no longer click the items past that height. Asking for the full height lets the popup move up as before; it only scrolls when the screen is too short. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/panels/timeline.rs | 128 +++++++++++---------- crates/ui-egui/tests/edit_point_menu_ui.rs | 29 ++++- 2 files changed, 94 insertions(+), 63 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a73611886..33c243762 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2861,76 +2861,80 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } resp.context_menu(|ui| { - ui.set_min_width(220.0); - if let Some(marker) = ctx.data(|d| d.get_temp::>(marker_menu_id)).flatten() { - marker_menu(app, &ctx, ui, marker); - return; - } - if let Some(target) = ctx.data(|d| d.get_temp::>(edit_point_menu_id)).flatten() { - edit_point_menu(app, &ctx, ui, seq, target); - return; - } - if ctx.data(|d| d.get_temp::(gap_menu_id)).unwrap_or(false) { - gap_menu(app, &ctx, ui); - return; - } - let sel = app.session.state.selection.clone(); - let picked: Vec<&filmcraft_project::TrackItem> = sel.iter().filter_map(|c| seq.find_item(*c).map(|(_, it)| it)).collect(); - let all_enabled = !picked.is_empty() && picked.iter().all(|it| it.enabled); - let linked = picked.iter().any(|it| it.link.is_some()); - let mut first = true; - for group in CLIP_MENU { - if !std::mem::take(&mut first) { - ui.separator(); + // a long menu scrolls instead of running off the bottom of the screen (#2852) + let max_height = (ui.ctx().content_rect().height() - 16.0).max(0.0); + egui::ScrollArea::vertical().id_salt("clip-menu-scroll").max_height(max_height).min_scrolled_height(max_height).show(ui, |ui| { + ui.set_min_width(220.0); + if let Some(marker) = ctx.data(|d| d.get_temp::>(marker_menu_id)).flatten() { + marker_menu(app, &ctx, ui, marker); + return; } - for &(label, cmd) in *group { - if cmd == "edit.label" { - ui.add_enabled_ui(!picked.is_empty(), |ui| { - let menu = ui.menu_button(crate::i18n::t(label), |ui| { - // the user's label names and colours, as in Edit ▸ Label (#786) - let labels = app.session.prefs.labels.clone(); - for l in filmcraft_project::Label::ALL { - if crate::menus::label_choice(ui, &mut app.auto, &labels, l, "timeline.clipMenu.label").clicked() { - if let Err(error) = app.session.execute("edit.label", json!({"label":l.name()})) { - app.ui.status = error.to_string(); + if let Some(target) = ctx.data(|d| d.get_temp::>(edit_point_menu_id)).flatten() { + edit_point_menu(app, &ctx, ui, seq, target); + return; + } + if ctx.data(|d| d.get_temp::(gap_menu_id)).unwrap_or(false) { + gap_menu(app, &ctx, ui); + return; + } + let sel = app.session.state.selection.clone(); + let picked: Vec<&filmcraft_project::TrackItem> = sel.iter().filter_map(|c| seq.find_item(*c).map(|(_, it)| it)).collect(); + let all_enabled = !picked.is_empty() && picked.iter().all(|it| it.enabled); + let linked = picked.iter().any(|it| it.link.is_some()); + let mut first = true; + for group in CLIP_MENU { + if !std::mem::take(&mut first) { + ui.separator(); + } + for &(label, cmd) in *group { + if cmd == "edit.label" { + ui.add_enabled_ui(!picked.is_empty(), |ui| { + let menu = ui.menu_button(crate::i18n::t(label), |ui| { + // the user's label names and colours, as in Edit ▸ Label (#786) + let labels = app.session.prefs.labels.clone(); + for l in filmcraft_project::Label::ALL { + if crate::menus::label_choice(ui, &mut app.auto, &labels, l, "timeline.clipMenu.label").clicked() { + if let Err(error) = app.session.execute("edit.label", json!({"label":l.name()})) { + app.ui.status = error.to_string(); + } + ui.close(); } - ui.close(); + } + }); + app.auto.add_response("timeline.clipMenu.edit.label", &menu.response, crate::i18n::t(label)); + }); + continue; + } + if cmd == "clip.multicam" { + multicam_menu(app, ui, &picked); + continue; + } + let label = match cmd { + "clip.enable" if all_enabled => tl!("✓ Enable"), + "clip.link" if linked => tl!("Unlink"), + _ => crate::i18n::t(label), + }; + let r = ui.add_enabled(!sel.is_empty() && app.session.is_enabled(cmd), egui::Button::new(label)); + app.auto.add(&format!("timeline.clipMenu.{cmd}"), r.rect, label); + if r.clicked() { + match crate::menus::invoke(app, &ctx, cmd, json!({})) { + // Unlink (#220): the clips were selected together because they were linked; + // keep only the one right-clicked, so it can be dragged away from the others + Ok(v) if cmd == "clip.link" && v["linked"] == json!(false) => { + if let Some(clip) = ctx.data(|d| d.get_temp::(menu_clip_id)) + && app.session.state.selection.contains(&clip) + { + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); } } - }); - app.auto.add_response("timeline.clipMenu.edit.label", &menu.response, crate::i18n::t(label)); - }); - continue; - } - if cmd == "clip.multicam" { - multicam_menu(app, ui, &picked); - continue; - } - let label = match cmd { - "clip.enable" if all_enabled => tl!("✓ Enable"), - "clip.link" if linked => tl!("Unlink"), - _ => crate::i18n::t(label), - }; - let r = ui.add_enabled(!sel.is_empty() && app.session.is_enabled(cmd), egui::Button::new(label)); - app.auto.add(&format!("timeline.clipMenu.{cmd}"), r.rect, label); - if r.clicked() { - match crate::menus::invoke(app, &ctx, cmd, json!({})) { - // Unlink (#220): the clips were selected together because they were linked; - // keep only the one right-clicked, so it can be dragged away from the others - Ok(v) if cmd == "clip.link" && v["linked"] == json!(false) => { - if let Some(clip) = ctx.data(|d| d.get_temp::(menu_clip_id)) - && app.session.state.selection.contains(&clip) - { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); - } + Ok(_) => {} + Err(e) => app.ui.status = e, } - Ok(_) => {} - Err(e) => app.ui.status = e, + ui.close(); } - ui.close(); } } - } + }); }); // ---- drops: project items and effects diff --git a/crates/ui-egui/tests/edit_point_menu_ui.rs b/crates/ui-egui/tests/edit_point_menu_ui.rs index d4e259b50..afe545975 100644 --- a/crates/ui-egui/tests/edit_point_menu_ui.rs +++ b/crates/ui-egui/tests/edit_point_menu_ui.rs @@ -18,11 +18,15 @@ struct Driver { impl Driver { fn new() -> Self { + Self::with_height(980.0) + } + + fn with_height(height: f32) -> Self { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); let (tx, rx) = channel(); let app = FilmcraftApp::new(s).with_control(rx); - let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let harness = Harness::builder().with_size(egui::vec2(1600.0, height)).with_max_steps(10_000).build_eframe(move |_cc| app); let mut d = Driver { harness, tx }; d.frames(4); d @@ -66,6 +70,14 @@ impl Driver { v.as_array().unwrap().iter().any(|e| e["id"] == json!(id)) } + /// [x, y, width, height] + fn rect(&mut self, id: &str) -> [f64; 4] { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + [r[0], r[1], r[2], r[3]] + } + fn seq(&mut self) -> Sequence { self.harness.state_mut().session.active_sequence().unwrap().clone() } @@ -158,3 +170,18 @@ fn join_through_edits_joins_only_the_right_clicked_cut() { d.click("timeline.editPointMenu.joinThroughEdits"); assert_eq!(d.seq().video_tracks[0].items.len(), before, "disabled: nothing joined"); } + +/// #2852: on a short screen the clip menu scrolls, so the items past its bottom can be reached. +#[test] +fn the_clip_menu_scrolls_on_a_short_screen() { + let height = 600.0; + let mut d = Driver::with_height(height as f32); + let (_, b) = d.cut_on_v1(); + d.right_click(b, 0.5); + let last = "timeline.clipMenu.sequence.joinThroughEdits"; + assert!(d.rect(last)[1] > height, "the menu is taller than the screen"); + d.ok("ui.scroll", json!({"id": "timeline.clipMenu.edit.cut", "dy": -2000.0})); + d.frames(10); + let r = d.rect(last); + assert!(r[1] >= 0.0 && r[1] + r[3] <= height, "Join Through Edits scrolled into view: {r:?}"); +} From 199b70c1d1d031432bf1f4ba6fb08eb1f86205ea Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:51:39 +0200 Subject: [PATCH 004/110] security(interchange): bound imported track indices (#2910) --- crates/interchange/src/common.rs | 31 ++++++++++++++++++++++++++---- crates/interchange/src/edl.rs | 8 ++++++-- crates/interchange/src/fcp7.rs | 15 ++++++++++----- crates/interchange/src/fcpxml.rs | 13 ++++++++----- crates/interchange/tests/fcpxml.rs | 10 ++++++++++ 5 files changed, 61 insertions(+), 16 deletions(-) diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs index 9d4ed6b05..be47a4317 100644 --- a/crates/interchange/src/common.rs +++ b/crates/interchange/src/common.rs @@ -13,6 +13,11 @@ use filmcraft_time::{FrameRate, Tick}; use crate::{Imported, Report}; +/// A timeline import may not materialise an unbounded number of sparse track +/// labels. This remains well above common editorial timelines while keeping +/// hostile lane/channel numbers from allocating every intervening track. +pub(crate) const MAX_IMPORTED_TRACKS: usize = 256; + // --------------------------------------------------------------------------------------------- // Paths and URLs // --------------------------------------------------------------------------------------------- @@ -410,12 +415,18 @@ impl Builder { Track::new(id, kind, name) } - pub fn ensure_tracks(&mut self, seq: &mut Sequence, kind: TrackKind, n: usize) { + /// Ensure `n` tracks of one kind exist. Returns false when an imported + /// label would exceed the supported per-kind track count. + pub fn ensure_tracks(&mut self, seq: &mut Sequence, kind: TrackKind, n: usize) -> bool { + if n > MAX_IMPORTED_TRACKS { + return false; + } while seq.tracks(kind).len() < n { let i = seq.tracks(kind).len(); let t = self.track(kind, i); seq.tracks_mut(kind).push(t); } + true } pub fn link_id(&mut self) -> u64 { @@ -465,15 +476,17 @@ impl Builder { /// Place `item` on the first track at or after `preferred` where it does not overlap anything, /// creating tracks as needed. Returns the track index. - pub fn place(&mut self, seq: &mut Sequence, kind: TrackKind, preferred: usize, item: TrackItem) -> usize { + pub fn place(&mut self, seq: &mut Sequence, kind: TrackKind, preferred: usize, item: TrackItem) -> Option { let mut i = preferred; loop { - self.ensure_tracks(seq, kind, i + 1); + if i >= MAX_IMPORTED_TRACKS || !self.ensure_tracks(seq, kind, i + 1) { + return None; + } let t = &mut seq.tracks_mut(kind)[i]; if !t.items.iter().any(|o| o.start < item.end() && item.start < o.end()) { t.items.push(item); t.sort(); - return i; + return Some(i); } i += 1; } @@ -723,4 +736,14 @@ mod tests { assert_eq!(rate_from_f64(48000.0), FrameRate::new(48000, 1)); assert_eq!(frames_round(FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5) + Tick(10)), 5); } + + #[test] + fn imported_tracks_are_bounded() { + let mut b = Builder::new("tracks"); + let mut seq = empty_sequence(Default::default()); + assert!(b.ensure_tracks(&mut seq, TrackKind::Video, MAX_IMPORTED_TRACKS)); + assert_eq!(seq.video_tracks.len(), MAX_IMPORTED_TRACKS); + assert!(!b.ensure_tracks(&mut seq, TrackKind::Video, MAX_IMPORTED_TRACKS + 1)); + assert_eq!(seq.video_tracks.len(), MAX_IMPORTED_TRACKS); + } } diff --git a/crates/interchange/src/edl.rs b/crates/interchange/src/edl.rs index ecee03914..5f114254c 100644 --- a/crates/interchange/src/edl.rs +++ b/crates/interchange/src/edl.rs @@ -375,7 +375,9 @@ impl Ctx<'_> { } } let id = ti.id; - self.b.ensure_tracks(&mut self.seq, kind, idx + 1); + if !self.b.ensure_tracks(&mut self.seq, kind, idx + 1) { + continue; + } let mut next = self.b.p.next_id; let track = &mut self.seq.tracks_mut(kind)[idx]; overwrite(track, ti, &mut || { @@ -487,7 +489,9 @@ fn import_event(ctx: &mut Ctx, ev: &Event, report: &mut Report) { let chans: BTreeSet = l2.chans.iter().copied().collect(); for ch in chans { let (kind, idx) = ctx.track_index(ch, 0); - ctx.b.ensure_tracks(&mut ctx.seq, kind, idx + 1); + if !ctx.b.ensure_tracks(&mut ctx.seq, kind, idx + 1) { + continue; + } let to = placed.iter().find(|(k, i, _)| *k == kind && *i == idx).map(|p| p.2); let track = &mut ctx.seq.tracks_mut(kind)[idx]; let from = if from_black { None } else { track.items.iter().find(|i| i.end() == t0).map(|i| i.id) }; diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index e0e9075d9..2bc67d9e7 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -27,9 +27,9 @@ use filmcraft_time::{FrameRate, Tick, format_timecode_frames}; use roxmltree::Node; use crate::common::{ - Builder, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, frames_round, gain_to_db, item_media, item_path, kind_from_ext, label_from, on_frame, - param, param_modified, path_to_file_url, rate_from_timebase, resolve_path, set_param, settings_for, standard_effects, timebase, transition_effect, - transition_name, unscaled, + Builder, MAX_IMPORTED_TRACKS, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, frames_round, gain_to_db, item_media, item_path, kind_from_ext, + label_from, on_frame, param, param_modified, path_to_file_url, rate_from_timebase, resolve_path, set_param, settings_for, standard_effects, timebase, + transition_effect, transition_name, unscaled, }; use crate::xml::{XmlWriter, bool_str, child, child_bool, child_f64, child_i64, child_text, children, elements, num, path_text}; use crate::{Error, ExportOptions, Format, ImportOptions, Imported, Report, Result}; @@ -343,7 +343,10 @@ impl<'a, 'i> Imp<'a, 'i, '_> { for (kind, tag) in [(TrackKind::Video, "video"), (TrackKind::Audio, "audio")] { let Some(m) = media.and_then(|m| child(m, tag)) else { continue }; for (ti, tn) in children(m, "track").enumerate() { - self.b.ensure_tracks(&mut seq, kind, ti + 1); + if !self.b.ensure_tracks(&mut seq, kind, ti + 1) { + self.report.warn(format!("FCP7 {kind:?} tracks beyond {MAX_IMPORTED_TRACKS} were skipped")); + break; + } self.track(&mut seq, id, kind, ti, tn, rate, (w, h)); } } @@ -378,7 +381,9 @@ impl<'a, 'i> Imp<'a, 'i, '_> { let t = &mut seq.tracks_mut(kind)[ti]; if t.items.iter().any(|o| o.start < ti_item.end() && ti_item.start < o.end()) { self.report.warn("overlapping clip items on one track; the later one was moved to a new track"); - self.b.place(seq, kind, ti + 1, ti_item); + if self.b.place(seq, kind, ti + 1, ti_item).is_none() { + self.report.warn(format!("FCP7 {kind:?} track exceeds the {MAX_IMPORTED_TRACKS}-track import limit; clip was skipped")); + } } else { t.items.push(ti_item); } diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs index 61482367e..438e22fbc 100644 --- a/crates/interchange/src/fcpxml.rs +++ b/crates/interchange/src/fcpxml.rs @@ -26,8 +26,8 @@ use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use roxmltree::Node; use crate::common::{ - Builder, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, gain_to_db, generator_of, item_media, item_path, kind_from_ext, param, - param_modified, path_to_file_url, resolve_path, scaled, set_param, settings_for, standard_effects, transition_effect, transition_name, + Builder, MAX_IMPORTED_TRACKS, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, gain_to_db, generator_of, item_media, item_path, kind_from_ext, + param, param_modified, path_to_file_url, resolve_path, scaled, set_param, settings_for, standard_effects, transition_effect, transition_name, }; use crate::xml::{child, children, elements, num}; use crate::{Error, ExportOptions, Format, ImportOptions, Imported, Report, Result}; @@ -437,14 +437,17 @@ impl<'a, 'i> Imp<'a, 'i, '_> { TrackKind::Video => lane.max(0) as usize, TrackKind::Audio => { if lane < 0 { - (-lane - 1) as usize + lane.unsigned_abs() as usize - 1 } else { lane as usize } } }; let id = ti.id; - self.b.place(seq, kind, pref, ti); + if self.b.place(seq, kind, pref, ti).is_none() { + self.report.warn(format!("FCPXML {kind:?} lane exceeds the {MAX_IMPORTED_TRACKS}-track import limit; clip was skipped")); + continue; + } placed.push((kind, id)); if kind == TrackKind::Video { vclip = Some(id); @@ -462,7 +465,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { let Some(l) = c.attribute("lane").and_then(|l| l.parse::().ok()) else { continue }; let off = attr_time(c, "offset").unwrap_or(Tick::ZERO); let ct0 = t0 + (off - local_start); - let clane = if lane > 0 && l > 0 { lane + l } else { l }; + let clane = if lane > 0 && l > 0 { lane.saturating_add(l) } else { l }; if c.has_tag_name("spine") { // Secondary storyline: children offsets are in the storyline's local time. let first = elements(c).filter(|x| !x.has_tag_name("transition")).find_map(|x| attr_time(x, "offset")).unwrap_or(Tick::ZERO); diff --git a/crates/interchange/tests/fcpxml.rs b/crates/interchange/tests/fcpxml.rs index 12892da57..740852a7a 100644 --- a/crates/interchange/tests/fcpxml.rs +++ b/crates/interchange/tests/fcpxml.rs @@ -163,6 +163,16 @@ fn golden_connected_clips() { assert!(matches!(&m.media, MediaRef::File { path } if path == "/proj/boats.mov")); } +#[test] +fn hostile_lane_numbers_do_not_create_sparse_tracks() { + let d = doc().replace("lane=\"-1\"", "lane=\"-2147483648\"").replace("lane=\"1\"", "lane=\"2147483647\""); + let (imp, rep) = import(d.as_bytes(), Format::Fcpxml, Some("/proj")).unwrap(); + let s = imp.project.sequence(only_seq(&imp)).unwrap(); + assert!(s.video_tracks.len() <= 256); + assert!(s.audio_tracks.len() <= 256); + assert!(rep.has_warnings()); +} + fn project() -> (Project, filmcraft_project::ItemId) { let r = FrameRate::FPS_29_97; let mut p = Project::new("Lib"); From f6e053b352a8747baa538d6c292457d207046a8d Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:51:44 +0200 Subject: [PATCH 005/110] security(text): bound text and phonemizer work (#2870) * security(text): bound layout and font-name work * security(tts): bound phonemizer input expansion --- crates/text/src/layout.rs | 10 +++- crates/text/src/sfnt.rs | 62 +++++++++++++++++--- crates/tts-text/src/phonemize.rs | 99 +++++++++++++++++++++++--------- 3 files changed, 135 insertions(+), 36 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 50fbed58e..68d485e7e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -544,10 +544,18 @@ fn paragraph(text: &str, base: usize, sty: &Styles, para: &ParagraphStyle) -> Ve let mut cluster_starts: Vec = extents.keys().copied().collect(); cluster_starts.sort_unstable(); let char_bytes: Vec = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect(); + let mut first_member = 0usize; for (ci, &cs) in cluster_starts.iter().enumerate() { let (x0, x1, rtl) = extents[&cs]; let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end); - let members: Vec = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect(); + while first_member < char_bytes.len() && char_bytes[first_member] < cs { + first_member += 1; + } + let member_start = first_member; + while first_member < char_bytes.len() && char_bytes[first_member] < next { + first_member += 1; + } + let members = &char_bytes[member_start..first_member]; let n = members.len().max(1) as f32; for (mi, b) in members.iter().enumerate() { let f = mi as f32 / n; diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index c000f4423..fec6ed38d 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -108,22 +108,43 @@ fn name_string(b: &[u8], id: u16) -> Option { if nid != id { continue; } - let raw = b.get(storage + off..storage + off + len)?; - let (rank, s) = match plat { + let rank = match plat { 3 if enc == 1 || enc == 10 => { + if best.as_ref().is_some_and(|(current, _)| *current <= if lang == 0x409 { 0 } else { 1 }) { + continue; + } + if lang == 0x409 { 0 } else { 1 } + } + 0 => { + if best.as_ref().is_some_and(|(current, _)| *current <= 2) { + continue; + } + 2 + } + 1 if enc == 0 => { + if best.as_ref().is_some_and(|(current, _)| *current <= 3) { + continue; + } + 3 + } + _ => continue, + }; + // Only decode a record that can improve the selected name. A hostile name table may + // point thousands of records at the same huge string. + let raw = b.get(storage + off..storage + off + len)?; + let s = match plat { + 3 => { let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u)) + String::from_utf16_lossy(&u) } 0 => { let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (2, String::from_utf16_lossy(&u)) + String::from_utf16_lossy(&u) } - 1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()), - _ => continue, + 1 => raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect(), + _ => return None, }; - if best.as_ref().is_none_or(|(r0, _)| rank < *r0) { - best = Some((rank, s)); - } + best = Some((rank, s)); } best.map(|(_, s)| s).filter(|s| !s.trim().is_empty()) } @@ -185,4 +206,27 @@ mod tests { assert_eq!(faces.len(), 1); assert_eq!((faces[0].index, faces[0].family.as_str(), faces[0].style.as_str()), (1, "Inter", "Regular")); } + + #[test] + fn later_non_improving_name_records_are_not_decoded() { + // The first record is the preferred Windows-English value. The second names an invalid + // range: it must be ignored instead of making the whole name lookup fail. + let mut table = vec![0; 30]; + table[2..4].copy_from_slice(&2u16.to_be_bytes()); + table[4..6].copy_from_slice(&30u16.to_be_bytes()); + let first = 6usize; + table[first..first + 2].copy_from_slice(&3u16.to_be_bytes()); + table[first + 2..first + 4].copy_from_slice(&1u16.to_be_bytes()); + table[first + 4..first + 6].copy_from_slice(&0x409u16.to_be_bytes()); + table[first + 6..first + 8].copy_from_slice(&1u16.to_be_bytes()); + table[first + 8..first + 10].copy_from_slice(&4u16.to_be_bytes()); + let second = first + 12; + table[second..second + 2].copy_from_slice(&3u16.to_be_bytes()); + table[second + 2..second + 4].copy_from_slice(&1u16.to_be_bytes()); + table[second + 4..second + 6].copy_from_slice(&0x409u16.to_be_bytes()); + table[second + 6..second + 8].copy_from_slice(&1u16.to_be_bytes()); + table[second + 8..second + 10].copy_from_slice(&u16::MAX.to_be_bytes()); + table.extend_from_slice(&[0, b'A', 0, b'B']); + assert_eq!(name_string(&table, 1).as_deref(), Some("AB")); + } } diff --git a/crates/tts-text/src/phonemize.rs b/crates/tts-text/src/phonemize.rs index e380cc5d9..d3e0882df 100644 --- a/crates/tts-text/src/phonemize.rs +++ b/crates/tts-text/src/phonemize.rs @@ -27,6 +27,10 @@ pub const MAX_CHUNK_CHARS: usize = 400; const MAX_DICT_BYTES: usize = 16 * 1024 * 1024; /// Maximum number of dictionary entries. const MAX_DICT_ENTRIES: usize = 500_000; +/// Most ARPAbet symbols retained for one dictionary pronunciation. +const MAX_PRONUNCIATION_PHONES: usize = 256; +/// Long unknown words are not useful narration and can make suffix fallback recurse repeatedly. +const MAX_LETTER_TO_SOUND_CHARS: usize = 512; /// Maximum number of overrides honoured (excess ignored, not an error). const MAX_OVERRIDES: usize = 1_000; /// Maximum length of one override value (longer ones ignored). @@ -98,7 +102,7 @@ impl Lexicon { skipped += 1; continue; }; - let phones: Vec<&str> = parts.collect(); + let phones: Vec<&str> = parts.by_ref().take(MAX_PRONUNCIATION_PHONES + 1).collect(); if phones.is_empty() { if had_comment { continue; @@ -106,6 +110,10 @@ impl Lexicon { skipped += 1; continue; } + if phones.len() > MAX_PRONUNCIATION_PHONES { + skipped += 1; + continue; + } // `WORD(2)` alternates fold into the same key. let key = match (head.find('('), head.rfind(')')) { (Some(open), Some(close)) if close > open + 1 && head.get(open + 1..close).is_some_and(|d| d.chars().all(|c| c.is_ascii_digit())) => { @@ -298,19 +306,34 @@ fn flush_sentence(sentence: &mut Vec, chunks: &mut Vec, lex: &Lexi /// Split a phoneme string longer than [`MAX_CHUNK_CHARS`] at the last `,` `;` `:` /// before the limit, else at the last space, else hard-split at the limit. fn split_long_chunk(s: String) -> Vec { - if s.chars().count() <= MAX_CHUNK_CHARS { - return vec![s]; + let mut out = Vec::new(); + let mut start = 0usize; + while start < s.len() { + let rest = &s[start..]; + let Some((limit, _)) = rest.char_indices().nth(MAX_CHUNK_CHARS) else { + let piece = rest.trim().to_string(); + if !piece.is_empty() { + out.push(piece); + } + break; + }; + let head = &rest[..limit]; + let cut = head + .char_indices() + .rev() + .find(|&(_, c)| matches!(c, ',' | ';' | ':')) + .map(|(i, _)| i + 1) + .or_else(|| head.rfind(' ').map(|i| i + 1)) + .unwrap_or(limit); + let piece = head[..cut].trim_end(); + if !piece.is_empty() { + out.push(piece.to_string()); + } + start += cut; + while s.get(start..).and_then(|rest| rest.chars().next()).is_some_and(char::is_whitespace) { + start += s[start..].chars().next().map(char::len_utf8).unwrap_or(0); + } } - let limit = s.char_indices().map(|(i, _)| i).nth(MAX_CHUNK_CHARS).unwrap_or(s.len()); - let head_boundary = s.get(..limit).and_then(|head| { - head.char_indices().rev().find(|&(_, c)| matches!(c, ',' | ';' | ':')).map(|(i, _)| i + 1).or_else(|| head.rfind(' ').map(|i| i + 1)).or(Some(limit)) - }); - let Some(cut) = head_boundary else { return vec![s] }; - let head = s.get(..cut).unwrap_or("").trim_end().to_string(); - let tail = s.get(cut..).unwrap_or("").trim_start().to_string(); - let mut out = vec![head]; - out.extend(split_long_chunk(tail)); - out.retain(|p| !p.is_empty()); out } @@ -698,7 +721,7 @@ fn past_ed(last: &Phone) -> &'static str { /// syllable (on the vowel before -tion/-sion). Always pronounceable. fn letter_to_sound(word: &str) -> Option { let letters: Vec = word.chars().filter(|c| c.is_ascii_alphabetic()).collect(); - if letters.is_empty() { + if letters.is_empty() || letters.len() > MAX_LETTER_TO_SOUND_CHARS { return None; } // -ture / -sure endings (nature, measure): the e belongs to the suffix, so it @@ -741,10 +764,9 @@ fn letter_to_sound(word: &str) -> Option { let mut i = 0usize; while i < n { let Some(rest) = chars.get(i..) else { break }; - let rest_str: String = rest.iter().collect(); let at_start = i == 0; // Multi-letter rules first (longest match wins). - if rest_str.ends_with("tion") && rest_str.len() == 4 { + if rest.len() == 4 && rest.iter().copied().eq("tion".chars()) { // The vowel before the suffix takes the primary stress. if let Some(&last) = vowel_positions.last() && let Some(v) = out.get_mut(last) @@ -756,7 +778,7 @@ fn letter_to_sound(word: &str) -> Option { out.push("n".into()); break; } - if rest_str.ends_with("sion") && rest_str.len() == 4 { + if rest.len() == 4 && rest.iter().copied().eq("sion".chars()) { if let Some(&last) = vowel_positions.last() && let Some(v) = out.get_mut(last) { @@ -767,35 +789,35 @@ fn letter_to_sound(word: &str) -> Option { out.push("n".into()); break; } - if rest_str.starts_with("sch") { + if rest.starts_with(&['s', 'c', 'h']) { out.push("s".into()); out.push("k".into()); i += 3; continue; } - if rest_str == "sten" { + if rest == ['s', 't', 'e', 'n'] { // listen out.push("s".into()); out.push("ə".into()); out.push("n".into()); break; } - if rest_str == "gue" { + if rest == ['g', 'u', 'e'] { out.push("ɡ".into()); break; } - if rest_str == "que" { + if rest == ['q', 'u', 'e'] { out.push("k".into()); break; } - if rest_str.starts_with("ough") { + if rest.starts_with(&['o', 'u', 'g', 'h']) { // A famously irregular group; pick the "thought" vowel, f when final. out.push("ɔ".into()); - if rest_str == "ough" { + if rest == ['o', 'u', 'g', 'h'] { out.push("f".into()); break; } - if rest_str.starts_with("ought") { + if rest.starts_with(&['o', 'u', 'g', 'h', 't']) { out.push("t".into()); i += 5; continue; @@ -803,13 +825,13 @@ fn letter_to_sound(word: &str) -> Option { i += 3; continue; } - if rest_str.starts_with("gu") && rest_str.len() > 2 { + if rest.starts_with(&['g', 'u']) && rest.len() > 2 { // The u is silent before a vowel (guard, guess). out.push("ɡ".into()); i += 2; continue; } - if rest_str.starts_with("ps") && at_start { + if rest.starts_with(&['p', 's']) && at_start { out.push("s".into()); i += 2; continue; @@ -1170,6 +1192,18 @@ PERCENT P ER0 S EH1 N T assert_eq!(lex.pronunciations("TRAIL").map(|v| v.first().map(String::as_str)), Some(Some("AE1 T"))); } + #[test] + fn skips_an_unreasonably_long_dictionary_pronunciation() { + let mut input = String::from("GOOD AE1\nTOO_LONG"); + for _ in 0..=MAX_PRONUNCIATION_PHONES { + input.push_str(" AE1"); + } + input.push('\n'); + let lex = Lexicon::parse_cmudict(input.as_bytes()).unwrap(); + assert_eq!(lex.len(), 1); + assert_eq!(lex.skipped(), 1); + } + #[test] fn refuses_oversized_dictionaries() { let oversized = vec![b'a'; MAX_DICT_BYTES + 1]; @@ -1335,6 +1369,19 @@ PERCENT P ER0 S EH1 N T } } + #[test] + fn chunk_splitting_is_iterative_for_many_chunks() { + let input = "a".repeat(MAX_CHUNK_CHARS * 2_000); + let chunks = split_long_chunk(input); + assert_eq!(chunks.len(), 2_000); + assert!(chunks.iter().all(|chunk| chunk.len() == MAX_CHUNK_CHARS)); + } + + #[test] + fn letter_to_sound_refuses_an_absurdly_long_unknown_word() { + assert!(letter_to_sound(&"ture".repeat(MAX_LETTER_TO_SOUND_CHARS)).is_none()); + } + #[test] fn am_reads_the_letter_name_not_the_article() { for text in ["At 9:00 a.m.", "At 9:00am"] { From 9f16d9775000d9def31977bf2d36cf203f35f208 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:51:49 +0200 Subject: [PATCH 006/110] Raise a loaded project's next_id past the ids already in it (#2864) * Raise a loaded project's next_id past the ids already in it (fixes #2809) * Demo project: start new ids past its fixed transition and marker ids (#2809) * Count transcript, source graphic and narration keys as ids in use on load (#2809) * Format tests: a transcript's key raises next_id on load (#2809) * Count ids that are only referenced (clip items, subclip parents, transition ends, sends, outputs, cameras, merged clips) on load (#2809) --- crates/engine/src/demo.rs | 2 + crates/format/src/lib.rs | 12 ++ crates/format/tests/fixtures.rs | 202 +++++++++++++++++++++++++++++++- crates/project/src/lib.rs | 71 +++++++++++ 4 files changed, 286 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index c60fc0f53..18fdf3cca 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -220,5 +220,7 @@ fn build_demo(pool: &MediaPool) -> Option<(Project, ItemId)> { for tr in s.all_tracks_mut() { tr.sort(); } + // the transitions and markers above take fixed ids: new ones start past them + p.next_id = p.next_id.max(p.max_id().saturating_add(1)); Some((p, seq)) } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index c88f9ef15..9ecd10aab 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -244,6 +244,18 @@ fn validate_loaded(mut loaded: Loaded) -> Result { sequence.check_bounds().map_err(|e| FormatError::Corrupt(format!("sequence `{}`: {e}", item.name)))?; } } + // Every new id is `next_id`, read from the file as is. One that lags behind the ids in it (an + // older build, a hand edit) would hand out an id in use, and the new item replace the old (#2809). + let project = &mut loaded.project; + let max = project.max_id(); + let past = max.checked_add(1).ok_or_else(|| FormatError::Corrupt(format!("project ID space exhausted (id {max} in use)")))?; + if project.next_id < past { + log::warn!("next id {} is not past the largest id in the project, raised to {past}", project.next_id); + project.next_id = past; + } + if project.next_id > Project::MAX_NEXT_ID { + return Err(FormatError::Corrupt(format!("project ID space exhausted (next id {})", project.next_id))); + } Ok(loaded) } diff --git a/crates/format/tests/fixtures.rs b/crates/format/tests/fixtures.rs index e99a7853f..6f1c9922a 100644 --- a/crates/format/tests/fixtures.rs +++ b/crates/format/tests/fixtures.rs @@ -35,7 +35,8 @@ fn v1_edit_loads_with_defaults_for_later_fields() { assert_eq!(l.schema_version, 1); let p = &l.project; assert_eq!(p.name, "Legacy Edit"); - assert_eq!(p.next_id, 48); + // the file says 48, but its sequence marker is 800000: new ids start past it (#2809) + assert_eq!(p.next_id, 800_001); let media = p.item(ItemId(5)).unwrap(); assert_eq!(media.name, "Ocean_Sunset.mp4"); assert!(media.metadata.is_empty()); @@ -219,6 +220,8 @@ fn v8_loads_without_transcripts_and_v9_roundtrips_them() { t.normalize(); p.transcripts.insert(ItemId(7), std::sync::Arc::new(t)); let again = decode(&encode(&p, true)).unwrap(); + // the transcript's key is an id in use, so new ids start past it (#2809) + p.next_id = 8; assert!(!again.migrated()); assert_eq!(again.project, p); assert_eq!(again.project.transcripts[&ItemId(7)].speakers[0].name, "Speaker 1"); @@ -259,3 +262,200 @@ fn v10_minimal_loads_with_m3_11_defaults() { assert_eq!(again.schema_version, SCHEMA_VERSION); assert_eq!(&again.project, p); } + +/// #2809: `next_id` is read from the file, and nothing raised it past the ids already in it. A file +/// whose `next_id` lagged behind (an older build, a hand edit, a merge) handed out an id in use, and +/// the new item silently replaced the old one. Each kind of id is raised past on its own, so a kind +/// the loader forgets fails here. +#[test] +fn next_id_is_raised_past_every_id_in_the_file() { + use filmcraft_project::{ + BinId, Camera, Caption, CaptionFormat, CaptionTrack, Label, Marker, MarkerId, MarkerKind, MergedClip, MulticamSource, Narration, ProjectLut, SearchBin, + Track, TrackId, TrackKind, TrackSend, Transition, TransitionId, + }; + const BIG: u64 = 5_000_000; + let base = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let seq = ItemId(21); + let marker = |id| Marker { + id: MarkerId(id), + start: Tick::ZERO, + duration: Tick::ZERO, + name: String::new(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Green, + }; + let kinds: Vec<(&str, Box)> = vec![ + ( + "item", + Box::new(|p| { + p.next_id = BIG; + p.add_item("Layer", Label::Iris, p.item(ItemId(5)).unwrap().kind.clone(), None); + }), + ), + ( + "bin", + Box::new(|p| { + p.next_id = BIG; + p.add_bin("Bin", None); + }), + ), + ("search bin", Box::new(|p| p.search_bins.push(SearchBin { id: BinId(BIG), name: "Find".into(), query: Default::default() }))), + ("media marker", Box::new(move |p| p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap().markers.push(marker(BIG)))), + ("sequence marker", Box::new(move |p| p.sequence_mut(seq).unwrap().markers[0].id = MarkerId(BIG))), + ("track", Box::new(move |p| p.sequence_mut(seq).unwrap().audio_tracks[0].id = TrackId(BIG))), + ("submix track", Box::new(move |p| p.sequence_mut(seq).unwrap().submix_tracks.push(Track::new(TrackId(BIG), TrackKind::Audio, "Submix".into())))), + ("clip", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].id = ClipId(BIG))), + ("link", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].link = Some(BIG))), + ("group", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].group = Some(BIG))), + ("clip marker", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].markers.push(marker(BIG)))), + ( + "transition", + Box::new(move |p| { + let s = p.sequence_mut(seq).unwrap(); + let clip = s.video_tracks[0].items[0].clone(); + let effect = clip.effects[0].clone(); + s.video_tracks[0].transitions.push(Transition { + id: TransitionId(BIG), + effect, + start: clip.start, + duration: Tick::ZERO, + from: None, + to: Some(clip.id), + align: Default::default(), + reverse: false, + }); + }), + ), + ( + "caption track", + Box::new(move |p| p.sequence_mut(seq).unwrap().caption_tracks.push(CaptionTrack::new(TrackId(BIG), "Subtitle".into(), CaptionFormat::Subtitle))), + ), + ( + "caption", + Box::new(move |p| { + let mut track = CaptionTrack::new(TrackId(7), "Subtitle".into(), CaptionFormat::Subtitle); + track.captions.push(Caption { + id: ClipId(BIG), + start: Tick::ZERO, + duration: Tick(1), + text: "Hi".into(), + speaker: None, + cue_id: None, + settings: String::new(), + }); + p.sequence_mut(seq).unwrap().caption_tracks.push(track); + }), + ), + ( + "LUT", + Box::new(|p| p.luts.push(ProjectLut { id: format!("lut{BIG}"), name: "Look".into(), source_path: None, format: "cube".into(), text: "".into() })), + ), + // left behind by a deleted item: a new item must not take its id and inherit them + ("transcript", Box::new(|p| _ = p.transcripts.insert(ItemId(BIG), Default::default()))), + ("source graphic", Box::new(|p| _ = p.source_graphics.insert(ItemId(BIG), Default::default()))), + ( + "narration", + Box::new(|p| { + let narration = Narration { + text: "Hello".into(), + language: "en-US".into(), + voice: "basic-female".into(), + pitch: Default::default(), + pace: 1.0, + speech_duration: Tick(1), + }; + p.narrations.insert(ItemId(BIG), narration); + }), + ), + // only referenced, the target gone: a new item taking that id would be picked up by the reference + ("clip's item", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].item = ItemId(BIG))), + ( + "subclip's parent", + Box::new(|p| { + let range = filmcraft_time::TimeRange::new(Tick::ZERO, Tick(1)); + p.add_item("Subclip", Label::Iris, ItemKind::Subclip { parent: ItemId(BIG), range, restrict_trims: false }, None); + }), + ), + ( + "transition's clip", + Box::new(move |p| { + let s = p.sequence_mut(seq).unwrap(); + let clip = s.video_tracks[0].items[0].clone(); + s.video_tracks[0].transitions.push(Transition { + id: TransitionId(9), + effect: clip.effects[0].clone(), + start: clip.start, + duration: Tick::ZERO, + from: None, + to: Some(ClipId(BIG)), + align: Default::default(), + reverse: false, + }); + }), + ), + ("send target", Box::new(move |p| p.sequence_mut(seq).unwrap().audio_tracks[0].mixer.sends.push(TrackSend::new(TrackId(BIG))))), + ("track output", Box::new(move |p| p.sequence_mut(seq).unwrap().master_mixer.output = Some(TrackId(BIG)))), + ( + "camera's source", + Box::new(move |p| { + let camera = Camera { name: "Cam 1".into(), video_track: None, audio_tracks: Vec::new(), enabled: true, source: Some(ItemId(BIG)) }; + p.sequence_mut(seq).unwrap().multicam = Some(MulticamSource { cameras: vec![camera], ..Default::default() }); + }), + ), + ( + "camera's track", + Box::new(move |p| { + let camera = Camera { name: "Cam 1".into(), video_track: Some(TrackId(BIG)), audio_tracks: Vec::new(), enabled: true, source: None }; + p.sequence_mut(seq).unwrap().multicam = Some(MulticamSource { cameras: vec![camera], ..Default::default() }); + }), + ), + ("merged clip", Box::new(move |p| p.sequence_mut(seq).unwrap().merged = Some(MergedClip { audio: vec![ItemId(BIG)], ..Default::default() }))), + ]; + for (kind, add) in &kinds { + let mut p = base.clone(); + add(&mut p); + p.next_id = 1; + let loaded = decode(&encode(&p, false)).unwrap_or_else(|e| panic!("{kind}: {e}")); + assert_eq!(loaded.project.next_id, BIG + 1, "{kind}"); + } +} + +/// #2809: the case the issue describes. A file whose `next_id` names an item already in it: the next +/// new item used to take that id and replace the item. It gets a fresh id, and both are still there +/// after a save and a reload. +#[test] +fn a_new_item_never_replaces_one_from_a_file_with_a_stale_next_id() { + let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project; + p.next_id = 5; + let mut p = decode(&encode(&p, false)).unwrap().project; + let before = p.items.len(); + let kind = p.item(ItemId(5)).unwrap().kind.clone(); + let new = p.add_item("Copy", filmcraft_project::Label::Iris, kind, None); + assert_ne!(new, ItemId(5)); + assert_eq!(p.items.len(), before + 1, "a new item replaced an old one"); + assert_eq!(p.item(ItemId(5)).unwrap().name, "Ocean_Sunset.mp4"); + let again = decode(&encode(&p, false)).unwrap().project; + assert_eq!(again, p, "the round trip keeps both items and the raised next_id"); + assert!(again.next_id > new.0); +} + +/// #2809: ids past what can ever be allocated. The largest possible id has no successor, and a +/// `next_id` that high overflows `alloc_id`; such a file is refused instead of panicking (debug) or +/// wrapping to 0 (release) on the first new item. +#[test] +fn an_exhausted_id_space_is_refused_on_load() { + use filmcraft_project::MarkerId; + let base = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let mut p = base.clone(); + p.sequence_mut(ItemId(21)).unwrap().markers[0].id = MarkerId(u64::MAX); + let e = decode(&encode(&p, false)).unwrap_err(); + assert!(matches!(&e, FormatError::Corrupt(m) if m.contains("ID space exhausted")), "{e}"); + let mut p = base.clone(); + p.next_id = u64::MAX; + let e = decode(&encode(&p, false)).unwrap_err(); + assert!(matches!(&e, FormatError::Corrupt(m) if m.contains("ID space exhausted")), "{e}"); + let mut p = base; + p.next_id = Project::MAX_NEXT_ID; + assert_eq!(decode(&encode(&p, false)).unwrap().project.next_id, Project::MAX_NEXT_ID, "the last usable next_id still opens"); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 5d95c5b80..5b21cdaec 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1344,6 +1344,11 @@ impl Default for Project { } impl Project { + /// The highest `next_id` a loaded project may carry. Ids are handed out one at a time, so no + /// amount of editing gets near it; a file past it is damaged, and refusing it keeps + /// `alloc_id` from overflowing. + pub const MAX_NEXT_ID: u64 = u64::MAX / 2; + pub fn new(name: &str) -> Self { Self { name: name.into(), @@ -1366,6 +1371,72 @@ impl Project { id } + /// The largest id in use, of any kind. They all come from `next_id`, so a project read from a + /// file must have its `next_id` past this. Ids that are only referenced count too (a clip whose + /// item is gone): a new item taking that id would be picked up by the reference. The root bin + /// counts, so there is always one. + pub fn max_id(&self) -> u64 { + let mut max = 0; + let mut see = |id: u64| max = max.max(id); + let mut bins = vec![&self.root]; + while let Some(bin) = bins.pop() { + see(bin.id.0); + for c in &bin.children { + match c { + BinEntry::Item(i) => see(i.0), + BinEntry::Bin(sub) => bins.push(sub), + } + } + } + self.search_bins.iter().for_each(|b| see(b.id.0)); + // deleting an item leaves these entries behind: a new item taking that id would inherit them + self.transcripts.keys().chain(self.source_graphics.keys()).chain(self.narrations.keys()).for_each(|i| see(i.0)); + // `lut.import` names LUTs `lut` + self.luts.iter().filter_map(|l| l.id.strip_prefix("lut")?.parse().ok()).for_each(&mut see); + fn mixer(m: &MixerStrip) -> impl Iterator + '_ { + m.sends.iter().map(|s| s.target.0).chain(m.output.map(|t| t.0)) + } + for (id, item) in &self.items { + see(id.0); + see(item.id.0); + match &item.kind { + ItemKind::Media(m) => m.markers.iter().for_each(|mk| see(mk.id.0)), + ItemKind::Subclip { parent, .. } => see(parent.0), + ItemKind::Sequence(s) => { + s.markers.iter().for_each(|mk| see(mk.id.0)); + mixer(&s.master_mixer).for_each(&mut see); + for t in s.all_tracks().chain(&s.submix_tracks) { + see(t.id.0); + mixer(&t.mixer).for_each(&mut see); + for c in &t.items { + see(c.id.0); + see(c.item.0); + c.link.into_iter().chain(c.group).for_each(&mut see); + c.markers.iter().for_each(|mk| see(mk.id.0)); + } + for tr in &t.transitions { + see(tr.id.0); + tr.from.into_iter().chain(tr.to).for_each(|c| see(c.0)); + } + } + for t in &s.caption_tracks { + see(t.id.0); + t.captions.iter().for_each(|c| see(c.id.0)); + } + for cam in s.multicam.iter().flat_map(|m| &m.cameras) { + cam.video_track.iter().chain(&cam.audio_tracks).for_each(|t| see(t.0)); + cam.source.iter().for_each(|i| see(i.0)); + } + if let Some(m) = &s.merged { + m.video.iter().chain(&m.audio).for_each(|i| see(i.0)); + } + } + _ => {} + } + } + max + } + /// Add an item to a bin (root when None / not found). pub fn add_item(&mut self, name: &str, label: Label, kind: ItemKind, bin: Option) -> ItemId { let id = ItemId(self.alloc_id()); From 04a4a7bc72d6b121542dd567f044b110ca89a463 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:51:54 +0200 Subject: [PATCH 007/110] security(proxies): reserve outputs atomically (#2922) * security(proxies): reserve outputs atomically * proxies: ReservedOutput::file returns an Option instead of expect() Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/proxies.rs | 95 ++++++++++++++++++++++++++++++------ crates/export/src/lib.rs | 15 ++++++ 2 files changed, 96 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index d6c720fa6..68204d2af 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -96,6 +96,19 @@ pub fn transcode( out: &str, preset: &Preset, progress: &Progress, +) -> std::result::Result { + transcode_with_file(clip, name, src, range, out, None, preset, progress) +} + +fn transcode_with_file( + clip: &MediaClip, + name: &str, + src: SharedSource, + range: Option, + out: &str, + output_file: Option>, + preset: &Preset, + progress: &Progress, ) -> std::result::Result { let info = &clip.info; let rate = clip.frame_rate(); @@ -140,6 +153,7 @@ pub fn transcode( as u32, part_of_batch: true, prores_profile: preset.prores.to_string(), + output_file, ..Default::default() }; let project = Arc::new(p); @@ -147,6 +161,59 @@ pub fn transcode( filmcraft_export::export(&project, seq, &settings, &provider, progress).map_err(|e| e.to_string()) } +/// An output created exclusively while choosing its name. Unless kept after a successful +/// transcode, dropping it removes only that file (including on a cancelled or rejected job). +struct ReservedOutput { + path: PathBuf, + file: Option>, + keep: bool, +} + +impl ReservedOutput { + fn path(&self) -> &Path { + &self.path + } + + fn file(&self) -> Option> { + self.file.clone() + } + + fn preserve(&mut self) { + self.keep = true; + } +} + +impl Drop for ReservedOutput { + fn drop(&mut self) { + if !self.keep { + // Release our handle before removal, which is required on Windows. + self.file.take(); + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// Reserve a numbered output name without following a later replacement of its pathname. +fn reserve_unique_path(dir: &Path, stem: &str, suffix: &str, ext: &str, taken: &[String]) -> Result { + std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let mut n = 1; + loop { + let name = if n == 1 { format!("{stem}{suffix}.{ext}") } else { format!("{stem}{suffix} {n}.{ext}") }; + let path = dir.join(name); + if taken.contains(&path.to_string_lossy().into_owned()) { + n += 1; + continue; + } + match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => { + return Ok(ReservedOutput { path, file: Some(Arc::new(file)), keep: false }); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", path.display()))), + } + } +} + /// Frames a transcode of `clip` will write (for progress totals). pub fn frame_count(clip: &MediaClip, range: Option) -> u64 { let rate = clip.frame_rate(); @@ -184,17 +251,17 @@ fn items_param(s: &Session, p: &Value) -> Vec { } /// Start a background transcode job over `work` (item, output path, preset). Returns the job id. -pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'static Preset)>, on_done: OnDone, wait: bool) -> Result { +fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, ReservedOutput, &'static Preset)>, on_done: OnDone, wait: bool) -> Result { let mut tasks = Vec::new(); let mut total = 0; - for (item, out, pr) in work { + for (item, output, pr) in work { let (clip, _, name) = media_path(&s.project, item).ok_or_else(|| bad("media.createProxies", format!("item {} is not file media", item.0)))?; let src = s.media.full_res_source(&s.project, item, &*s.services).ok_or_else(|| EngineError::Other(format!("{name}: no source")))?; if s.media.offline_status(item).is_some() { return Err(EngineError::Other(format!("{name} is offline; link it first"))); } total += frame_count(&clip, None); - tasks.push((item, clip, name, src, out, pr)); + tasks.push((item, clip, name, src, output, pr)); } let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1; let job = crate::Job { id, label, progress: Default::default(), result: Default::default() }; @@ -206,22 +273,20 @@ pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'st let mut bytes = 0; let mut err = None; let n = tasks.len(); - for (k, (item, clip, name, src, out, pr)) in tasks.into_iter().enumerate() { + for (k, (item, clip, name, src, mut output, pr)) in tasks.into_iter().enumerate() { if prog.cancel.load(std::sync::atomic::Ordering::Relaxed) { err = Some("cancelled".to_string()); break; } *prog.status.lock().unwrap_or_else(|e| e.into_inner()) = format!("{name} ({} of {n})", k + 1); - if let Some(d) = Path::new(&out).parent() { - let _ = std::fs::create_dir_all(d); - } - match transcode(&clip, &name, src, None, &out, pr, &prog) { + let out = output.path().to_string_lossy().into_owned(); + match transcode_with_file(&clip, &name, src, None, &out, output.file(), pr, &prog) { Ok(r) => { bytes += r.bytes; + output.preserve(); outs.lock().unwrap_or_else(|e| e.into_inner()).push((item, out)); } Err(e) => { - let _ = std::fs::remove_file(&out); err = Some(format!("{name}: {e}")); break; } @@ -371,14 +436,15 @@ pub fn create(s: &mut Session, p: &Value) -> Result { } }; let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name); - let out = unique_path(&proxy_dir(&path, dest.as_deref()), &stem, "_Proxy", pr.extension, &taken).to_string_lossy().into_owned(); + let output = reserve_unique_path(&proxy_dir(&path, dest.as_deref()), &stem, "_Proxy", pr.extension, &taken)?; + let out = output.path().to_string_lossy().into_owned(); taken.push(out.clone()); - work.push((i, out, pr)); + work.push((i, output, pr)); } if work.is_empty() { return Err(EngineError::Other(format!("nothing to proxy: {}", skipped.iter().filter_map(|v| v["reason"].as_str()).collect::>().join("; ")))); } - let outputs: Vec = work.iter().map(|(i, o, _)| json!({"item": i.0, "path": o})).collect(); + let outputs: Vec = work.iter().map(|(i, o, _)| json!({"item": i.0, "path": o.path()})).collect(); let n = work.len(); let label = format!("Create Proxies ({n} clip{})", if n == 1 { "" } else { "s" }); let attach = bool_p(p, "attach").unwrap_or(true); @@ -481,9 +547,10 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { _ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"), }; let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name); - let out = unique_path(&dir, &stem, "", pr.extension, &taken).to_string_lossy().into_owned(); + let output = reserve_unique_path(&dir, &stem, "", pr.extension, &taken)?; + let out = output.path().to_string_lossy().into_owned(); taken.push(out.clone()); - work.push((i, out, pr)); + work.push((i, output, pr)); } if !work.is_empty() { let id = start_job(s, format!("Ingest: transcode {} clip(s)", work.len()), work, OnDone::ReplaceMedia, false)?; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 46956912f..fbd3413fd 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -406,6 +406,10 @@ pub struct ExportSettings { /// writing `path` (hosts without a filesystem: the web app offers the file as a download). #[serde(skip)] pub sink: Option, + /// An already-created primary output file. Internal callers use this to retain an exclusive + /// reservation made while choosing an output name, rather than reopening that path later. + #[serde(skip)] + pub output_file: Option>, } /// Receives in-memory export output: `(path, bytes)` per finished file. @@ -432,6 +436,10 @@ enum Out { impl Out { fn create(settings: &ExportSettings) -> Result { + if let Some(file) = &settings.output_file { + let file = file.try_clone().map_err(|e| ExportError::Io(e.to_string()))?; + return Ok(Out::File(std::io::BufWriter::new(file))); + } Out::create_path(settings, &settings.path) } @@ -440,6 +448,12 @@ impl Out { if settings.sink.is_some() { return Ok(Out::Mem(std::io::Cursor::new(Vec::new()))); } + if path == settings.path + && let Some(file) = &settings.output_file + { + let file = file.try_clone().map_err(|e| ExportError::Io(e.to_string()))?; + return Ok(Out::File(std::io::BufWriter::new(file))); + } let f = std::fs::File::create(path).map_err(|e| ExportError::Io(format!("{path}: {e}")))?; Ok(Out::File(std::io::BufWriter::new(f))) } @@ -588,6 +602,7 @@ impl Default for ExportSettings { h264_pass: H264Pass::Single, signal: ColorSignal::default(), sink: None, + output_file: None, } } } From 19ff23cba345c64c3a86675a70091669a9670a57 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:51:59 +0200 Subject: [PATCH 008/110] security(hevc): validate slice payload before allocation (#2939) --- crates/hevc/src/decoder.rs | 3 +++ crates/hevc/src/params.rs | 24 +++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/hevc/src/decoder.rs b/crates/hevc/src/decoder.rs index 97a0fb7f9..d6fcbbea9 100644 --- a/crates/hevc/src/decoder.rs +++ b/crates/hevc/src/decoder.rs @@ -340,6 +340,9 @@ impl Decoder { )?; sps.check_supported()?; pps.check_supported()?; + // `start_picture` creates the frame and DPB state. Do not allocate those for a NAL that + // contains only a syntactically valid slice header and no CABAC payload. + ensure!(sh.data_offset < rbsp.len(), "empty slice segment data"); if first { self.start_picture(&sh, &sps, &pps, pts)?; } else { diff --git a/crates/hevc/src/params.rs b/crates/hevc/src/params.rs index a7f7daf0b..9b24d433b 100644 --- a/crates/hevc/src/params.rs +++ b/crates/hevc/src/params.rs @@ -6,6 +6,16 @@ use crate::spec_tables::{DEFAULT_SCALING_INTER, DEFAULT_SCALING_INTRA}; use crate::tables::scan_diag; use filmcraft_bitstream::BitReader; +/// HEVC picture state includes several per-pixel and per-block work buffers. This permits 8K +/// pictures while rejecting dimensions that could exhaust memory during decode. +pub const MAX_LUMA_SAMPLES: u64 = 1 << 26; + +fn check_picture_size(width: u32, height: u32) -> Result<()> { + ensure!(width > 0 && height > 0 && width <= 16888 && height <= 16888, "picture size {width}x{height} out of range"); + ensure!(u64::from(width) * u64::from(height) <= MAX_LUMA_SAMPLES, "picture size {width}x{height} exceeds the decoded-frame budget"); + Ok(()) +} + /// General profile / tier / level information. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ProfileTierLevel { @@ -478,7 +488,7 @@ impl Sps { let separate_colour_plane = if chroma_format_idc == 3 { r.read_flag()? } else { false }; let width = r.read_ue()?; let height = r.read_ue()?; - ensure!(width > 0 && height > 0 && width <= 16888 && height <= 16888, "picture size {width}x{height} out of range"); + check_picture_size(width, height)?; let mut conf_win = (0, 0, 0, 0); if r.read_flag()? { conf_win = (r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?); @@ -921,3 +931,15 @@ impl Layout { self.tile_id[self.rs_to_ts[rs as usize] as usize] } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picture_size_has_an_aggregate_pixel_budget() { + check_picture_size(8192, 8192).unwrap(); + let err = check_picture_size(8192, 8193).unwrap_err(); + assert!(err.to_string().contains("decoded-frame budget")); + } +} From 8fa72cf18da8798fea051b6610e1955f152039ef Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:03 +0200 Subject: [PATCH 009/110] security(codecs): avoid quadratic MPEG timestamp lookup (#2883) --- crates/codecs/src/mpeg.rs | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/codecs/src/mpeg.rs b/crates/codecs/src/mpeg.rs index e2cd09ba1..3ead983fe 100644 --- a/crates/codecs/src/mpeg.rs +++ b/crates/codecs/src/mpeg.rs @@ -369,15 +369,7 @@ impl MpegSource { let raw: Vec> = v.order.iter().map(|&i| units[v.units[i]].pts).collect(); let rate = self.info.frame_rate(); let dur = |k: i64| (k as i128 * 90_000 * rate.den as i128 / rate.num.max(1) as i128) as i64; - let known: Vec = (0..raw.len()).filter(|&i| raw[i].is_some()).collect(); - (0..raw.len()) - .map(|i| { - raw[i].or_else(|| { - let j = known.iter().rev().find(|&&j| j < i).or_else(|| known.iter().find(|&&j| j > i))?; - Some(raw[*j]? + dur(i as i64 - *j as i64)) - }) - }) - .collect() + interpolate_pts(&raw, dur) } fn read_unit(&self, v: &VideoTrack, i: usize) -> crate::Result> { @@ -690,6 +682,23 @@ impl MpegSource { } } +/// Fill missing timestamps from their closest preceding timestamp, or the next one when there is +/// no predecessor. The known positions are sorted, so binary search avoids rescanning them for +/// every missing frame. +fn interpolate_pts(raw: &[Option], dur: impl Fn(i64) -> i64) -> Vec> { + let known: Vec = (0..raw.len()).filter(|&i| raw[i].is_some()).collect(); + raw.iter() + .enumerate() + .map(|(i, &pts)| { + pts.or_else(|| { + let next = known.partition_point(|&j| j < i); + let j = next.checked_sub(1).or_else(|| known.get(next).map(|_| next))?; + Some(raw[known[j]]? + dur(i as i64 - known[j] as i64)) + }) + }) + .collect() +} + fn chroma_name(c: filmcraft_frame::Chroma) -> &'static str { match c { filmcraft_frame::Chroma::C420 => "4:2:0", @@ -1015,4 +1024,16 @@ mod tests { assert_eq!(order, vec![0, 4, 5, 3]); assert_eq!(pts, vec![0, -2, -3, 3, 1, 2]); } + + #[test] + fn timestamp_interpolation_handles_long_missing_runs_without_rescanning() { + let mut raw = vec![None; 10_000]; + raw[4_000] = Some(40_000); + raw[9_999] = Some(99_990); + let pts = interpolate_pts(&raw, |frames| frames * 10); + assert_eq!(pts[0], Some(0)); + assert_eq!(pts[3_999], Some(39_990)); + assert_eq!(pts[4_001], Some(40_010)); + assert_eq!(pts[9_998], Some(99_980)); + } } From ccb80b2e9d2a6662e9ec693c475869cdaa2e1a82 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:08 +0200 Subject: [PATCH 010/110] security(platform): bound hybrid replay output (#2938) --- crates/platform/src/hybrid.rs | 36 ++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/platform/src/hybrid.rs b/crates/platform/src/hybrid.rs index c010e79e0..8323938b8 100644 --- a/crates/platform/src/hybrid.rs +++ b/crates/platform/src/hybrid.rs @@ -21,6 +21,9 @@ const LOG_MAX_SAMPLES: usize = 600; const LOG_MAX_BYTES: usize = 256 << 20; /// Presentation times remembered as returned (older ones are forgotten first). const EMITTED_MAX: usize = 4096; +/// Pictures held while software replays a hardware run. The replay log is bounded by bytes and +/// samples, but an invalid stream can still make one sample produce many decoded pictures. +const REPLAY_OUTPUT_MAX: usize = 4096; /// A hardware decoder with a transparent software fallback. pub struct HybridDecoder { @@ -99,6 +102,16 @@ impl HybridDecoder { } } + /// Retain software replay output without letting a corrupt access unit turn the bounded + /// sample replay into an unbounded frame queue. + fn retain_replay(frames: &mut Vec, mut out: Vec) -> Result<()> { + if frames.len().saturating_add(out.len()) > REPLAY_OUTPUT_MAX { + return Err(CodecError::Decode("software fallback replay produced too many frames".into())); + } + frames.append(&mut out); + Ok(()) + } + /// In-band parameter sets (sequence header, key frame format) that differ from the sample /// entry's (the hardware session was set up from those). fn parameter_sets_changed(&self, sample: &[u8]) -> bool { @@ -157,7 +170,7 @@ impl HybridDecoder { for (i, (s, p)) in log.iter().enumerate() { let Some(sw) = self.sw.as_mut() else { break }; match sw.decode(s, *p) { - Ok(out) => frames.extend(out), + Ok(out) => Self::retain_replay(&mut frames, out)?, Err(e) if i == last => rejected = Some(e), Err(e) => log::warn!("software replay after the hardware decoder failed: {e}"), } @@ -250,3 +263,24 @@ impl VideoDecoder for HybridDecoder { } } } + +#[cfg(test)] +mod tests { + use filmcraft_frame::VideoFrame; + + use super::*; + + fn frame(pts: i64) -> DecodedFrame { + DecodedFrame { pts, frame: VideoFrame::rgba8(1, 1, vec![0, 0, 0, 255]), draft: false } + } + + #[test] + fn replay_output_has_a_hard_limit() { + let mut retained = Vec::new(); + HybridDecoder::retain_replay(&mut retained, (0..REPLAY_OUTPUT_MAX as i64).map(frame).collect()).unwrap(); + + let err = HybridDecoder::retain_replay(&mut retained, vec![frame(REPLAY_OUTPUT_MAX as i64)]).unwrap_err(); + assert!(err.to_string().contains("too many frames")); + assert_eq!(retained.len(), REPLAY_OUTPUT_MAX); + } +} From 370019bdfc239ad2329f0732246acd86ce618901 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:13 +0200 Subject: [PATCH 011/110] security(isobmff): bound metadata expansion (#2933) --- crates/isobmff/src/demux.rs | 39 ++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/isobmff/src/demux.rs b/crates/isobmff/src/demux.rs index b9b685ac0..c28ba5ccd 100644 --- a/crates/isobmff/src/demux.rs +++ b/crates/isobmff/src/demux.rs @@ -876,7 +876,7 @@ fn parse_udta(p: &[u8], md: &mut Metadata) { } else if b.kind.0[0] == 0xA9 && b.payload.len() >= 4 { let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; if let Some(s) = b.payload.get(4..4 + n) { - md.entries.push((b.kind.to_string_lossy(), String::from_utf8_lossy(s).into_owned())); + push_metadata(md, &b.kind.to_string_lossy(), s); } } } @@ -902,18 +902,40 @@ fn parse_meta(p: &[u8], md: &mut Metadata) { let Some(ilst) = find(body, b"ilst") else { return }; for item in boxes(ilst).map_while(|b| b.ok()) { let idx = item.kind.as_u32() as usize; - let key = if !keys.is_empty() && idx >= 1 && idx <= keys.len() { keys[idx - 1].clone() } else { item.kind.to_string_lossy() }; + let key = if !keys.is_empty() && idx >= 1 && idx <= keys.len() { + std::borrow::Cow::Borrowed(keys[idx - 1].as_str()) + } else { + std::borrow::Cow::Owned(item.kind.to_string_lossy()) + }; if let Some(d) = find(item.payload, b"data") && d.len() >= 8 { let ty = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) & 0xFF_FFFF; if ty == 1 || ty == 0 { - md.entries.push((key, String::from_utf8_lossy(&d[8..]).into_owned())); + push_metadata(md, &key, &d[8..]); } } } } +/// Metadata is untrusted presentation data. Bound its cumulative expanded representation before +/// cloning repeated numeric keys or converting values into owned strings. +const MAX_METADATA_BYTES: usize = 1 << 20; +const MAX_METADATA_ENTRIES: usize = 4_096; + +fn push_metadata(md: &mut Metadata, key: &str, value: &[u8]) { + if md.entries.len() >= MAX_METADATA_ENTRIES { + return; + } + let used = md.entries.iter().map(|(k, v)| k.len().saturating_add(v.len())).sum::(); + // Invalid UTF-8 can expand to one three-byte replacement character per input byte. + let worst = key.len().saturating_add(value.len().saturating_mul(3)); + if used.saturating_add(worst) > MAX_METADATA_BYTES { + return; + } + md.entries.push((key.to_owned(), String::from_utf8_lossy(value).into_owned())); +} + #[cfg(test)] mod tests { use super::*; @@ -936,6 +958,17 @@ mod tests { assert_eq!(c.next_value(), 0); } + #[test] + fn metadata_expansion_is_bounded_before_strings_are_retained() { + let mut md = Metadata::default(); + push_metadata(&mut md, "k", &vec![b'x'; MAX_METADATA_BYTES]); + assert!(md.entries.is_empty()); + for _ in 0..MAX_METADATA_ENTRIES + 10 { + push_metadata(&mut md, "key", b"value"); + } + assert_eq!(md.entries.len(), MAX_METADATA_ENTRIES); + } + #[test] fn rejects_no_moov_and_cmov() { let mut b = crate::bytes::BoxBuf::new(); From cbeb748eccde7872bcf13e75e4c8d1af62c08bb3 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:17 +0200 Subject: [PATCH 012/110] security(matroska): compose stripped private data once (#2911) --- crates/matroska/src/meta.rs | 56 ++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/crates/matroska/src/meta.rs b/crates/matroska/src/meta.rs index 7406be14f..3ae698646 100644 --- a/crates/matroska/src/meta.rs +++ b/crates/matroska/src/meta.rs @@ -525,22 +525,43 @@ pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec { if t.kind == TrackKind::Audio && t.audio.is_none() { t.audio = Some(AudioInfo::default()); } - // CodecPrivate may itself be header-stripped (scope bit 2 = private data) - for enc in &t.encodings { - if let ContentEncoding::HeaderStripping { bytes, scope } = enc - && scope & 2 != 0 - { - let mut p = bytes.clone(); - p.extend_from_slice(&t.codec_private); - t.codec_private = p; - } - } + // CodecPrivate may itself be header-stripped (scope bit 2 = private data). + // Compose all applicable prefixes once: repeatedly cloning the complete + // private data lets many compact encoding entries multiply allocation. + restore_codec_private(&t.encodings, &mut t.codec_private); t.codec = map_codec(&t.codec_id, &t.codec_private, t.audio.as_ref()); out.push(t); } out } +fn restore_codec_private(encodings: &[ContentEncoding], codec_private: &mut Vec) { + let prefix_len = encodings + .iter() + .filter_map(|enc| match enc { + ContentEncoding::HeaderStripping { bytes, scope } if scope & 2 != 0 && !bytes.is_empty() => Some(bytes.len()), + _ => None, + }) + .fold(0usize, usize::saturating_add); + if prefix_len == 0 { + return; + } + let Some(capacity) = prefix_len.checked_add(codec_private.len()) else { return }; + let mut restored = Vec::with_capacity(capacity); + // Each successive stripping encoding prepends its bytes, so restore in + // reverse declaration order to preserve the prior result. + for enc in encodings.iter().rev() { + if let ContentEncoding::HeaderStripping { bytes, scope } = enc + && scope & 2 != 0 + && !bytes.is_empty() + { + restored.extend_from_slice(bytes); + } + } + restored.extend_from_slice(codec_private); + *codec_private = restored; +} + #[cfg(test)] mod metadata_depth_tests { use super::*; @@ -558,6 +579,21 @@ mod metadata_depth_tests { assert!(current.children.is_empty()); } + #[test] + fn codec_private_header_stripping_is_composed_once_in_reverse_order() { + let encodings = vec![ + ContentEncoding::HeaderStripping { bytes: b"first".to_vec(), scope: 2 }, + ContentEncoding::HeaderStripping { bytes: Vec::new(), scope: 2 }, + ContentEncoding::HeaderStripping { bytes: b"second".to_vec(), scope: 2 }, + ContentEncoding::HeaderStripping { bytes: b"frame".to_vec(), scope: 1 }, + ]; + let mut private = b"private".to_vec(); + + restore_codec_private(&encodings, &mut private); + + assert_eq!(private, b"secondfirstprivate"); + } + #[test] fn deeply_nested_simple_tags_stop_at_depth_limit() { let data = [0x67, 0xC8, 0xFF].repeat(100_000); From 73265622b23fd2214e5029f67d95c27f4c14964b Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:21 +0200 Subject: [PATCH 013/110] security(h264enc): cache second-pass plan sums (#2906) --- crates/h264enc/src/ratecontrol.rs | 47 +++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/h264enc/src/ratecontrol.rs b/crates/h264enc/src/ratecontrol.rs index 9f77a6813..0fb27216c 100644 --- a/crates/h264enc/src/ratecontrol.rs +++ b/crates/h264enc/src/ratecontrol.rs @@ -81,7 +81,7 @@ pub enum RcKind { Qp(u8), Crf(f32), Abr { kbps: u32 }, - TwoPass { kbps: u32, plan: Vec, planned_bits: Vec }, + TwoPass { kbps: u32, plan: Vec, planned_bits: Vec, planned_total: f64, remaining_bits: Vec }, } pub struct RateControl { @@ -153,8 +153,15 @@ impl RateControl { } } let plan = qs((0.5 * (lo + hi)).exp()); - let planned_bits = stats.frames.iter().zip(&plan).map(|(f, &q)| f.bits as f64 * f.qscale / q).collect(); - RcKind::TwoPass { kbps, plan, planned_bits } + let planned_bits: Vec = stats.frames.iter().zip(&plan).map(|(f, &q)| f.bits as f64 * f.qscale / q).collect(); + let planned_total = planned_bits.iter().sum(); + // `remaining_bits[i]` is the inclusive suffix for frame `i`; retain an + // empty suffix so callers can index it without a special case. + let mut remaining_bits = vec![0.0; planned_bits.len() + 1]; + for i in (0..planned_bits.len()).rev() { + remaining_bits[i] = remaining_bits[i + 1] + planned_bits[i]; + } + RcKind::TwoPass { kbps, plan, planned_bits, planned_total, remaining_bits } } /// Choose the frame QP (fractional) for frame `index` (display order) of `t` with lookahead complexity `cplx`. @@ -198,17 +205,17 @@ impl RateControl { let q = rf * cplx.powf(1.0 - QCOMP) * type_factor(t); qscale2qp(q) } - RcKind::TwoPass { kbps, plan, planned_bits } => { + RcKind::TwoPass { kbps, plan, planned_bits, planned_total, remaining_bits } => { // Closed-loop second pass: `m` measures how far real frame sizes deviate from the first-pass model // (bits ∝ 1/qscale) at the qscales actually used; the remaining plan is rescaled so its predicted // size fits the remaining budget. let i = index_in_coding_order.min(plan.len().saturating_sub(1)); let bpf = *kbps as f64 * 1000.0 / self.fps; - let target_total: f64 = planned_bits.iter().sum(); + let target_total = *planned_total; let smooth = 0.5 * bpf * self.fps.max(1.0); let m = ((self.total_bits + smooth) / (self.model_done + smooth)).clamp(0.3, 3.0); let inflight: f64 = self.inflight.iter().sum(); - let remaining_plan: f64 = planned_bits[i..].iter().sum(); + let remaining_plan = remaining_bits[i]; let remaining_budget = (target_total - self.total_bits - m * inflight).max(0.05 * remaining_plan.max(1.0)); let f = (m * remaining_plan / remaining_budget).clamp(0.25, 4.0); let q = plan[i] * f; @@ -271,3 +278,31 @@ impl RateControl { self.pass1.push(FrameStat { slice_type: t, qscale: qp2qscale(qp), bits, cplx }); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_pass_plan_caches_total_and_inclusive_suffixes() { + let stats = PassStats { + fps: 24.0, + frames: vec![ + FrameStat { slice_type: SliceType::I, qscale: 1.0, bits: 2_000, cplx: 20.0 }, + FrameStat { slice_type: SliceType::P, qscale: 1.5, bits: 1_000, cplx: 10.0 }, + FrameStat { slice_type: SliceType::B, qscale: 2.0, bits: 500, cplx: 5.0 }, + ], + }; + + let RcKind::TwoPass { planned_bits, planned_total, remaining_bits, .. } = RateControl::plan_two_pass(&stats, 1_000) else { + panic!("two-pass planning must return a two-pass rate-control plan"); + }; + + assert_eq!(remaining_bits.len(), planned_bits.len() + 1); + assert!((planned_total - planned_bits.iter().sum::()).abs() < f64::EPSILON); + for i in 0..planned_bits.len() { + assert!((remaining_bits[i] - planned_bits[i..].iter().sum::()).abs() < f64::EPSILON); + } + assert_eq!(remaining_bits[planned_bits.len()], 0.0); + } +} From ebe15de14138911ed03b9153a8d9a52a12a11ab3 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:26 +0200 Subject: [PATCH 014/110] security(bench): isolate temporary export outputs (#2928) * security(bench): isolate temporary export outputs * chore(bench): record tempfile dependency --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/examples/bench/sections.rs | 12 ++++-------- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd3e6a624..1c9e2993a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2356,6 +2356,7 @@ dependencies = [ "rustix 1.1.5", "serde", "serde_json", + "tempfile", "toml 1.1.6+spec-1.1.0", "unicode-bidi", "web-time", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b63880d4e..f1d7b8991 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -53,6 +53,7 @@ filmcraft-codecs = { workspace = true } filmcraft-platform = { workspace = true } filmcraft-interchange = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } +tempfile = "3" # build.rs bakes contributors/contributors.json into the binary (About ▸ Contributors). [build-dependencies] diff --git a/crates/ui-egui/examples/bench/sections.rs b/crates/ui-egui/examples/bench/sections.rs index 27231b732..3f5192bef 100644 --- a/crates/ui-egui/examples/bench/sections.rs +++ b/crates/ui-egui/examples/bench/sections.rs @@ -454,8 +454,7 @@ pub fn export(o: &Opts) -> Vec { return Vec::new(); }; let secs = if o.quick { 3.0 } else { 8.0 }; - let dir = std::env::temp_dir().join(format!("filmcraft-bench-export-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("tmp dir"); + let dir = tempfile::Builder::new().prefix("filmcraft-bench-export-").tempdir().expect("tmp dir"); let mut rows = Vec::new(); // Scenes: (id, format, label, extension, effects-heavy). `--only` matches the id exactly here, // so `--only h264` runs the plain scene and `--only h264_fx` only the layered one. @@ -497,7 +496,7 @@ pub fn export(o: &Opts) -> Vec { }) .expect("audio"); frames = s.project.sequence(seq).map(|q| q.settings.frame_rate.frame_at(q.duration())).unwrap_or(0); - let out = dir.join(format!("out.{ext}")); + let out = dir.path().join(format!("out.{ext}")); filmcraft_export::reset_export_lock_wait(); filmcraft_gpu::export_renderer::timing::reset(); let (t0, c0) = (Instant::now(), cpu_now()); @@ -549,7 +548,6 @@ pub fn export(o: &Opts) -> Vec { "gpu_fallbacks": filmcraft_engine::export::gpu_render_stats().fallbacks.saturating_sub(gpu0.fallbacks), })); } - let _ = std::fs::remove_dir_all(&dir); rows } @@ -567,9 +565,8 @@ pub fn project(o: &Opts) -> Vec { for k in 0..5 { clips += big_sequence(&mut s, item, &format!("Sequence {}", k + 1), 11 + k as u64).1; } - let dir = std::env::temp_dir().join(format!("filmcraft-bench-project-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("tmp dir"); - let file = dir.join("big.fcproj"); + let dir = tempfile::Builder::new().prefix("filmcraft-bench-project-").tempdir().expect("tmp dir"); + let file = dir.path().join("big.fcproj"); let n = o.repeat.max(3); let (mut save, mut open) = (Vec::new(), Vec::new()); for _ in 0..n { @@ -583,7 +580,6 @@ pub fn project(o: &Opts) -> Vec { assert_eq!(s2.project.items.len(), s.project.items.len()); } let mbytes = std::fs::metadata(&file).map(|m| m.len()).unwrap_or(0) as f64 / 1e6; - let _ = std::fs::remove_dir_all(&dir); eprintln!("project: save {:.0} ms, open {:.0} ms ({clips} clips, {mbytes:.1} MB)", median(&save), median(&open)); let row = |what: &str, v: &[f64]| json!({"what": what, "clips": clips, "mbytes": mbytes, "p50_ms": median(v), "min_ms": v.iter().cloned().fold(f64::MAX, f64::min), "runs_ms": v, "load": load_avg()}); vec![row("save (file.saveAs)", &save), row("open (file.open)", &open)] From 9722d77aa552acdeb449bc47940fdab725fc5be0 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:30 +0200 Subject: [PATCH 015/110] security(engine): bound graphic template thumbnails (#2887) * security(engine): bound graphic template thumbnails * graphic templates: scale a capped thumbnail to fit both sides of the canvas Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/graphic_templates.rs | 20 +++++++++++++++++--- crates/engine/src/graphic_templates_tests.rs | 7 +++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/graphic_templates.rs b/crates/engine/src/graphic_templates.rs index 00b66c72f..01fa146a4 100644 --- a/crates/engine/src/graphic_templates.rs +++ b/crates/engine/src/graphic_templates.rs @@ -44,6 +44,7 @@ fn query(id: &'static str, label: &'static str, params: &'static str, run: Run) } const G: &[&str] = &["Graphics and Titles"]; +const MAX_THUMBNAIL_DIMENSION: u32 = 1920; fn has_graphic(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; @@ -149,8 +150,7 @@ fn entry_json(e: &LibraryEntry) -> Value { /// Render a template's thumbnail (static: rolls are shown at rest) as straight sRGB RGBA8, /// `w` pixels wide with the template's aspect ratio. Returns (width, height, pixels). pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec) { - let w = w.clamp(16, 1920); - let h = ((w as f64 * t.canvas[1] as f64 / t.canvas[0].max(1) as f64).round() as u32).max(1); + let (w, h) = thumbnail_dimensions(t.canvas, w); register_fonts(t); let mut meta = t.graphic.clone(); meta.roll.mode = RollMode::Off; @@ -171,11 +171,25 @@ pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec) { // a moment into the clip, so intro animations have settled a little let at = Tick((t.duration.0 / 2).min(filmcraft_time::TICKS_PER_SECOND)); let mut img = filmcraft_render::image::Image::new(w as usize, h as usize); - let k = w as f64 / t.canvas[0].max(1) as f64; + // fit both sides: a capped height leaves `w / canvas width` too large for a tall canvas + let k = (w as f64 / t.canvas[0].max(1) as f64).min(h as f64 / t.canvas[1].max(1) as f64); filmcraft_render::graphic_clip::render_graphic(&it, at, (t.canvas[0], t.canvas[1]), &Affine::scale(k, k), &mut img); (w, h, img.to_rgba8()) } +/// Fit a requested thumbnail width within a bounded rectangle without trusting the template's +/// aspect ratio to derive an unbounded second dimension. +fn thumbnail_dimensions(canvas: [u32; 2], requested_width: u32) -> (u32, u32) { + let mut w = requested_width.clamp(16, MAX_THUMBNAIL_DIMENSION); + let aspect = canvas[0].max(1) as f64 / canvas[1].max(1) as f64; + let mut h = (w as f64 / aspect).round().clamp(1.0, u32::MAX as f64) as u32; + if h > MAX_THUMBNAIL_DIMENSION { + h = MAX_THUMBNAIL_DIMENSION; + w = (h as f64 * aspect).round().clamp(1.0, MAX_THUMBNAIL_DIMENSION as f64) as u32; + } + (w, h) +} + /// Cached thumbnails by template id + content + width (the UI asks every frame). pub fn thumbnail_cached(t: &GraphicsTemplate, w: u32) -> Arc<(u32, u32, Vec)> { static CACHE: OnceLock)>>>> = OnceLock::new(); diff --git a/crates/engine/src/graphic_templates_tests.rs b/crates/engine/src/graphic_templates_tests.rs index e49c0ec8f..47a7c1528 100644 --- a/crates/engine/src/graphic_templates_tests.rs +++ b/crates/engine/src/graphic_templates_tests.rs @@ -143,6 +143,13 @@ fn builtin_templates_apply_and_thumbnail() { } } +#[test] +fn template_thumbnail_dimensions_fit_a_bounded_rectangle() { + assert_eq!(super::thumbnail_dimensions([1920, 1080], 160), (160, 90)); + assert_eq!(super::thumbnail_dimensions([1, 16_384], 1920), (1, 1920)); + assert_eq!(super::thumbnail_dimensions([16_384, 1], 1920), (1920, 1)); +} + #[test] fn responsive_time_protects_intro_and_outro_when_trimmed() { let mut s = demo("gt-time"); From 599516dc1cbc007c91385ae9452317f230cfc17f Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:35 +0200 Subject: [PATCH 016/110] fix(control): bound synthetic pointer events (#2868) * fix(control): bound synthetic pointer events * control: check ui.click's count before queuing any input events Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3b5391125..960fa8915 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -25,6 +25,17 @@ use std::sync::mpsc::Sender; use serde_json::{Value, json}; +const MAX_CLICK_COUNT: u64 = 10; +const MAX_DRAG_STEPS: u64 = 1_024; + +fn bounded_repetitions(params: &Value, key: &str, default: u64, minimum: u64, maximum: u64) -> Result { + let value = params.get(key).map(|value| value.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))).transpose()?.unwrap_or(default); + if value > maximum { + return Err(format!("`{key}` must not exceed {maximum}")); + } + Ok(value.max(minimum)) +} + use crate::FilmcraftApp; use crate::dock::PanelKind; use crate::state::{Mode, PlaybackRes, ThumbnailMode, Tool}; @@ -320,6 +331,15 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()), Err(e) => return err(e), }; + // validate before queuing anything, so a refused count leaves no modifier held + let count = if req.method == "ui.click" { + match bounded_repetitions(p, "count", 1, 0, MAX_CLICK_COUNT) { + Ok(count) => count, + Err(error) => return err(error), + } + } else { + 0 + }; let m = modifiers(p); app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(pos)); @@ -329,7 +349,6 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Some("middle") => egui::PointerButton::Middle, _ => egui::PointerButton::Primary, }; - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); for _ in 0..count { app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); @@ -345,7 +364,10 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) (Err(e), _) | (_, Err(e)) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()), (Err(e), _) | (_, Err(e)) => return err(e), }; - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); + let steps = match bounded_repetitions(p, "steps", 12, 2, MAX_DRAG_STEPS) { + Ok(steps) => steps, + Err(error) => return err(error), + }; let m = modifiers(p); app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); @@ -622,10 +644,20 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result, String> { #[cfg(test)] mod base64_tests { + use serde_json::json; + #[test] fn rfc4648_vectors() { for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] { assert_eq!(super::base64(i.as_bytes()), o); } } + + #[test] + fn synthetic_event_repetition_is_bounded() { + assert_eq!(super::bounded_repetitions(&json!({}), "count", 1, 0, super::MAX_CLICK_COUNT).unwrap(), 1); + assert_eq!(super::bounded_repetitions(&json!({"steps": 1}), "steps", 12, 2, super::MAX_DRAG_STEPS).unwrap(), 2); + assert!(super::bounded_repetitions(&json!({"count": u64::MAX}), "count", 1, 0, super::MAX_CLICK_COUNT).is_err()); + assert!(super::bounded_repetitions(&json!({"steps": "many"}), "steps", 12, 2, super::MAX_DRAG_STEPS).is_err()); + } } From 441e80b7a66ffb6cd9a0516564fc4ebd7fd14a6e Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:40 +0200 Subject: [PATCH 017/110] security(tts): make malformed-token scanning linear (#2871) * security(tts): make malformed-token scanning linear * tts-text: find a pause marker's ] by byte, so a multi-byte character at byte 64 doesn't hide it Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/tts-text/src/normalize.rs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/tts-text/src/normalize.rs b/crates/tts-text/src/normalize.rs index 46f74075c..34a213c32 100644 --- a/crates/tts-text/src/normalize.rs +++ b/crates/tts-text/src/normalize.rs @@ -44,6 +44,8 @@ const MAX_INPUT: usize = 1_048_576; const MAX_PAUSE_MS: u32 = 10_000; /// Numbers with more than this many digits are read digit by digit (never overflow). const MAX_CARDINAL_DIGITS: usize = 12; +/// A malformed pause marker is only inspected within this bounded prefix. +const MAX_PAUSE_MARKER_BYTES: usize = 64; /// Normalize `text` into spoken-word tokens for `lang`. /// @@ -157,7 +159,9 @@ pub fn normalize(text: &str, lang: Lang) -> Result, NormalizeError> { out.push(terminator); } } - _ if is_word_char(c) => match parse_wordish(rest, lang) { + // Apostrophes can occur inside a word, but an apostrophe-only run must not repeatedly + // scan the whole remaining script looking for a word. + _ if c.is_alphabetic() => match parse_wordish(rest, lang) { Some((tokens, used)) => { pos += used; out.extend(tokens); @@ -202,7 +206,8 @@ fn peek(s: &str, i: usize) -> Option { /// `[pause 1s]`, `[pause 500ms]`, `[pause 1.5s]` → a capped pause. Malformed markers /// return `None` and fall through to plain text (never an error). fn parse_pause(rest: &str) -> Option<(u32, usize)> { - let close = rest.find(']')?; + // search bytes: the 64-byte cut may fall inside a multi-byte character that follows the marker + let close = rest.as_bytes().iter().take(MAX_PAUSE_MARKER_BYTES).position(|&b| b == b']')?; let inner = rest.get(1..close)?.trim(); let value = inner.strip_prefix("pause")?; let value = value.trim_start(); @@ -952,12 +957,13 @@ fn year_words(year: u64, _lang: Lang) -> Vec { /// Words, abbreviations, months, acronyms, a.m./p.m. and e.g./i.e. fn parse_wordish(rest: &str, _lang: Lang) -> Option<(Vec, usize)> { - let collected: String = rest.chars().take_while(|c| is_word_char(*c)).collect(); + let end = rest.char_indices().find_map(|(i, c)| (!is_word_char(c)).then_some(i)).unwrap_or(rest.len()); + let collected = rest.get(..end)?; let word = collected.trim_matches(|c| c == '\'' || c == '’'); if word.is_empty() { return None; } - let used = word.len() + leading_apostrophes(rest); + let used = word.len(); let lower = word.to_ascii_lowercase(); // a.m. / p.m. — a single letter, a dot, "m", and a dot or boundary. @@ -999,10 +1005,6 @@ fn parse_wordish(rest: &str, _lang: Lang) -> Option<(Vec, usize)> { Some((vec![Token::Word(word.into())], used)) } -fn leading_apostrophes(rest: &str) -> usize { - rest.chars().take_while(|c| *c == '\'' || *c == '’').map(char::len_utf8).sum() -} - /// Expansions for `Mr.` etc.; the dot is consumed so it never ends a sentence. /// `St.` picks "saint" before a capitalised name and "street" otherwise. fn dotted_abbreviation(lower: &str, rest: &str, used: usize) -> Option<(Vec, usize)> { @@ -1493,6 +1495,9 @@ mod tests { assert_eq!(words("[pause 0.25s]", Lang::EnUs), "[pause 250]"); assert_eq!(words("[pause 11s]", Lang::EnUs), "[pause 10000]"); assert_eq!(words("[pause 999999999999s]", Lang::EnUs), "[pause 10000]"); + // byte 64 falls inside an `é` after the marker: still a pause + let tokens = normalize(&format!("[pause 1s]x{}", "é".repeat(40)), Lang::EnUs).unwrap(); + assert_eq!(tokens.first(), Some(&Token::Pause { millis: 1000 })); // Malformed markers stay plain text. assert_eq!(words("[pause", Lang::EnUs), "[ pause"); assert_eq!(words("[pause x]", Lang::EnUs), "[ pause x ]"); @@ -1545,6 +1550,14 @@ mod tests { assert_eq!(words("1,2345", Lang::EnUs), "one , two thousand three hundred forty-five"); } + #[test] + fn malformed_marker_and_apostrophe_runs_make_linear_progress() { + let brackets = "[".repeat(20_000); + let apostrophes = "'".repeat(20_000); + assert_eq!(normalize(&brackets, Lang::EnUs).unwrap().len(), 20_000); + assert_eq!(normalize(&apostrophes, Lang::EnUs).unwrap().len(), 20_000); + } + #[test] fn hostile_inputs_do_not_panic() { let hostile: Vec = vec![ From d3ec344691ac43d158e356da3210c83d65b785a5 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:45 +0200 Subject: [PATCH 018/110] security(ui): bound export folder elision work (#2902) * security(ui): bound export folder elision work * export mode: keep location_link's doc comment on the function Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panels/export_mode.rs | 60 +++++++++++++++++++++--- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 9dac65003..b8082433c 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -401,6 +401,23 @@ fn draft_text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, stored: &str, hint: &s (r.changed() && !v.trim().is_empty()).then_some(v) } +/// Longest folder tail the link lays out or shows on hover. +const MAX_LOCATION_DISPLAY_BYTES: usize = 4096; + +/// Keep the tail of a UTF-8 folder name without copying or splitting a codepoint. Project item +/// names can enter the generated export path, so UI display work must not scale with an arbitrary +/// imported name. +fn location_tail(s: &str) -> (&str, bool) { + if s.len() <= MAX_LOCATION_DISPLAY_BYTES { + return (s, false); + } + let mut start = s.len() - MAX_LOCATION_DISPLAY_BYTES; + while !s.is_char_boundary(start) { + start += 1; + } + (&s[start..], true) +} + /// The output folder as a link (folder icon, accent colour, underlined); the caller opens the save /// dialog on a click. A path too long for the row loses its start, so the folder's name stays. fn location_link(ui: &mut egui::Ui, t: &Tokens, folder: &str) -> egui::Response { @@ -408,18 +425,34 @@ fn location_link(ui: &mut egui::Ui, t: &Tokens, folder: &str) -> egui::Response let col = if resp.hovered() { t.accent_hover } else { t.accent }; crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 8.0, r.center().y), vec2(14.0, 14.0)), crate::icons::Icon::Folder, col); let room = (r.width() - 22.0).max(0.0); + let (folder, truncated) = location_tail(folder); let mut galley = ui.painter().layout_no_wrap(folder.to_string(), Tokens::ui(12.5), col); - for (i, _) in folder.char_indices().skip(1) { - if galley.size().x <= room { - break; - } - galley = ui.painter().layout_no_wrap(format!("…{}", folder.get(i..).unwrap_or("")), Tokens::ui(12.5), col); + if galley.size().x > room { + let starts: Vec = folder.char_indices().map(|(i, _)| i).collect(); + let suffix = if starts.len() > 1 { + let mut lo = 1usize; + let mut hi = starts.len() - 1; + while lo < hi { + let mid = (lo + hi) / 2; + let candidate = format!("…{}", &folder[starts[mid]..]); + if ui.painter().layout_no_wrap(candidate, Tokens::ui(12.5), col).size().x <= room { + hi = mid; + } else { + lo = mid + 1; + } + } + format!("…{}", &folder[starts[lo]..]) + } else { + "…".into() + }; + galley = ui.painter().layout_no_wrap(suffix, Tokens::ui(12.5), col); } let at = pos2(r.min.x + 20.0, r.center().y - galley.size().y / 2.0); let underline = at.y + galley.size().y; ui.painter().hline(at.x..=at.x + galley.size().x, underline, egui::Stroke::new(1.0, col)); ui.painter().galley(at, galley, col); - resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(folder) + let hover = if truncated { format!("…{folder}") } else { folder.to_string() }; + resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(hover) } const RATES: [(&str, FrameRate); 11] = [ @@ -1504,3 +1537,18 @@ fn list_row(ui: &mut egui::Ui, t: &Tokens, selected: bool, text: &str, width: f3 ui.painter().text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.5), if selected { Color32::WHITE } else { t.text }); resp } + +#[cfg(test)] +mod location_tests { + use super::*; + + #[test] + fn location_tail_is_bounded_and_keeps_utf8_intact() { + let input = format!("/{}終", "é".repeat(MAX_LOCATION_DISPLAY_BYTES)); + let (tail, truncated) = location_tail(&input); + assert!(truncated); + assert!(tail.len() <= MAX_LOCATION_DISPLAY_BYTES); + assert!(tail.ends_with('終')); + assert!(std::str::from_utf8(tail.as_bytes()).is_ok()); + } +} From 68cfb0b52d8ce3fe6ccd71ce552225b9c07c1e2b Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:52:49 +0200 Subject: [PATCH 019/110] security(text): keep font scans within configured folders (#2876) * security(text): keep font scans within configured folders * fonts: still find symlinked font files; skip only symlinked folders Co-Authored-By: Claude Opus 5.5 * fonts: rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + crates/text/Cargo.toml | 3 +++ crates/text/src/fonts.rs | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c9e2993a..ae7b24f2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2287,6 +2287,7 @@ dependencies = [ "filmcraft-color", "harfrust", "skrifa", + "tempfile", "unicode-bidi", "unicode-linebreak", "unicode-segmentation", diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 00989623d..811164495 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -21,3 +21,6 @@ unicode-bidi = "0.3.18" unicode-linebreak = "0.1.5" # UAX #29 extended grapheme clusters (MIT OR Apache-2.0). unicode-segmentation = "1.13" + +[dev-dependencies] +tempfile = "3.27" diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a6c3f444f..add11df73 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -114,16 +114,46 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for e in rd.flatten() { let p = e.path(); - if p.is_dir() { + let Ok(file_type) = e.file_type() else { continue }; + // Font discovery is bounded to the configured folders: never follow a symlinked folder + // into a loop or an unrelated tree. Symlinked font files (NixOS, home-manager, Debian + // alternatives) still count. + let is_file = if file_type.is_symlink() { std::fs::metadata(&p).is_ok_and(|m| m.is_file()) } else { file_type.is_file() }; + if file_type.is_dir() { if depth < 4 { walk(&p, depth + 1, out); } - } else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { + } else if is_file && p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { out.push(p); } } } +#[cfg(all(test, unix))] +mod directory_tests { + use super::walk; + + #[test] + fn directory_source_does_not_follow_symlinked_font_trees() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let configured = root.path().join("configured"); + let foreign = root.path().join("foreign"); + std::fs::create_dir_all(&configured).unwrap(); + std::fs::create_dir_all(&foreign).unwrap(); + std::fs::write(configured.join("inside.ttf"), b"").unwrap(); + std::fs::write(foreign.join("outside.ttf"), b"").unwrap(); + symlink(&foreign, configured.join("loop")).unwrap(); + symlink(foreign.join("outside.ttf"), configured.join("linked.ttf")).unwrap(); + + let mut found = Vec::new(); + walk(&configured, 0, &mut found); + found.sort(); + assert_eq!(found, vec![configured.join("inside.ttf"), configured.join("linked.ttf")]); + } +} + impl FontSource for DirectorySource { fn faces(&self) -> Vec { let mut files = Vec::new(); From 980c3883253fb92cede914e4bbae3fccc2581d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= <79342117+mercuryVM@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:52:55 -0300 Subject: [PATCH 020/110] codecs: walk open-GOP leading pictures back in a loop, not by recursion (#2920) * codecs: walk open-GOP leading pictures back in a loop, not by recursion MxfVideo::sync_before and MpegVideo::sync_before recursed one GOP back per leading picture, so a crafted index whose every GOP leads its key frame recursed once per GOP: tens of thousands of frames for a small file, a plausible stack overflow in dev builds. Both now use gop::open_gop_sync, a loop over the same steps. It stops when the index's sync sample lies past the picture instead of looping, and reads pts and keys with get(). Fixes #2803 * gop: keep decode_start's doc comment on decode_start Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/gop.rs | 27 +++++++++++++++++++++++++++ crates/codecs/src/mpeg.rs | 9 +++++++-- crates/codecs/src/mxf.rs | 5 +++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index 522b46b43..bcfd4f7ec 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -123,6 +123,20 @@ pub trait VideoSamples: Sync { fn make_decoder(&self) -> crate::Result>; } +/// A container's sync sample for `i` when leading pictures need the GOP before: `sync` is the +/// index's own sync sample, `leads(i, key)` says `i` is shown before `key`. One step per GOP, so a +/// loop: a crafted index can chain tens of thousands of GOPs (#2803). A `sync` past `i` stops the +/// walk instead of looping on it. +pub(crate) fn open_gop_sync(mut i: usize, sync: impl Fn(usize) -> usize, leads: impl Fn(usize, usize) -> bool) -> usize { + loop { + let key = sync(i); + if key == 0 || key > i || !leads(i, key) { + return key; + } + i = key - 1; + } +} + /// The sync sample to start decoding at for sample `i`: its sync sample, or an earlier one when /// `i` is a leading picture of an open GOP (stored after its random-access picture, shown before /// it, like the RASL pictures after every HEVC CRA of iPhone video). Those are predicted from the @@ -866,6 +880,19 @@ impl GopCache { #[cfg(test)] mod tests { + #[test] + fn open_gop_sync_walks_a_long_chain_of_leading_pictures_without_recursing() { + // Every GOP of 3 starts at a key whose leading picture is shown before it (#2803). + let gops = 200_000usize; + let sync = |i: usize| i - i % 3; + let leads = |i: usize, key: usize| i == key + 2; + assert_eq!(super::open_gop_sync(gops * 3 - 1, sync, leads), 0); + // A picture that is not a leading one keeps its own key. + assert_eq!(super::open_gop_sync(7, sync, |_, _| false), 6); + // An index whose sync sample lies past `i` ends the walk instead of looping. + assert_eq!(super::open_gop_sync(5, |_| 9, |_, _| true), 9); + } + use super::*; use crate::video::DecodedFrame; use std::collections::VecDeque; diff --git a/crates/codecs/src/mpeg.rs b/crates/codecs/src/mpeg.rs index 3ead983fe..040ca9092 100644 --- a/crates/codecs/src/mpeg.rs +++ b/crates/codecs/src/mpeg.rs @@ -769,10 +769,15 @@ impl VideoSamples for MpegVideo<'_> { } fn sync_before(&self, i: usize) -> usize { let i = i.min(self.v.keys.len().saturating_sub(1)); - let key = (0..=i).rev().find(|&k| self.v.keys[k]).unwrap_or(0); + let keys = &self.v.keys; + let pts = &self.v.pts; // a leading picture of an open GOP (shown before its random-access picture) needs the // previous GOP's pictures as references - if key > 0 && self.v.pts[i] < self.v.pts[key] { self.sync_before(key - 1) } else { key } + crate::gop::open_gop_sync( + i, + |i| (0..=i).rev().find(|&k| keys.get(k) == Some(&true)).unwrap_or(0), + |i, key| matches!((pts.get(i), pts.get(key)), (Some(a), Some(b)) if a < b), + ) } fn sample_at(&self, t: i64) -> Option { usize::try_from(t).ok().and_then(|t| self.v.order.get(t).copied()) diff --git a/crates/codecs/src/mxf.rs b/crates/codecs/src/mxf.rs index 1b230a8dc..cf8900a10 100644 --- a/crates/codecs/src/mxf.rs +++ b/crates/codecs/src/mxf.rs @@ -408,10 +408,11 @@ impl VideoSamples for MxfVideo<'_> { self.src.vpts[i] } fn sync_before(&self, i: usize) -> usize { - let key = self.src.file.tracks[self.track].sync_before(i); // a leading picture of an open GOP (stored after its random-access picture, shown // before it) is predicted from the previous GOP too - if key > 0 && i < self.src.vpts.len() && self.src.vpts[i] < self.src.vpts[key] { self.sync_before(key - 1) } else { key } + let track = &self.src.file.tracks[self.track]; + let pts = &self.src.vpts; + crate::gop::open_gop_sync(i, |i| track.sync_before(i), |i, key| matches!((pts.get(i), pts.get(key)), (Some(a), Some(b)) if a < b)) } fn sample_at(&self, t: i64) -> Option { usize::try_from(t).ok().and_then(|t| self.src.vorder.get(t).copied()) From 71d5f52b4d49b08733ad4a32df06b089f6586f50 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:00 +0200 Subject: [PATCH 021/110] security(opus): validate direct multistream headers (#2905) --- crates/opus/src/header.rs | 51 +++++++++++++++++++++++++++++++++- crates/opus/src/multistream.rs | 1 + 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/opus/src/header.rs b/crates/opus/src/header.rs index 2fe0c752a..2e51ce270 100644 --- a/crates/opus/src/header.rs +++ b/crates/opus/src/header.rs @@ -90,6 +90,38 @@ impl OpusHead { v } + /// Validate a header independently of its wire parser. This is required for callers of + /// [`crate::Decoder::from_head`], which may construct an `OpusHead` directly with `usize` + /// values that cannot occur in the byte format. + pub fn validate(&self) -> Result<()> { + if !(1..=255).contains(&self.channels) || self.mapping.len() != self.channels { + return Err(Error::InvalidHeader("bad channel count or mapping length")); + } + if self.mapping_family == 0 { + if self.channels > 2 || self.streams != 1 || self.coupled_streams != self.channels - 1 || self.mapping.iter().copied().ne(0..self.channels as u8) { + return Err(Error::InvalidHeader("invalid family 0 mapping")); + } + return Ok(()); + } + if self.mapping_family == 3 { + return Err(Error::Unsupported("channel mapping family 3 (projection)")); + } + if self.streams == 0 + || self.streams > 255 + || self.coupled_streams > self.streams + || self.streams.checked_add(self.coupled_streams).is_none_or(|n| n > 255) + { + return Err(Error::InvalidHeader("bad stream counts")); + } + if self.mapping_family == 1 && !(1..=8).contains(&self.channels) { + return Err(Error::InvalidHeader("family 1 allows 1 to 8 channels")); + } + if self.mapping.iter().any(|&m| m != 255 && m as usize >= self.streams + self.coupled_streams) { + return Err(Error::InvalidHeader("channel mapping out of range")); + } + Ok(()) + } + fn build(channels: usize, pre_skip: u16, input_sample_rate: u32, output_gain: i16, family: u8, rest: &[u8]) -> Result { if channels == 0 { return Err(Error::InvalidHeader("zero channels")); @@ -120,7 +152,9 @@ impl OpusHead { } (streams, coupled, mapping) }; - Ok(OpusHead { channels, pre_skip, input_sample_rate, output_gain, mapping_family: family, streams, coupled_streams: coupled, mapping }) + let head = OpusHead { channels, pre_skip, input_sample_rate, output_gain, mapping_family: family, streams, coupled_streams: coupled, mapping }; + head.validate()?; + Ok(head) } /// Linear output gain factor. @@ -176,4 +210,19 @@ mod tests { assert_eq!(head.pre_skip, 312); assert_eq!(head.channels, 2); } + + #[test] + fn directly_constructed_headers_are_checked_before_decoder_allocation() { + let head = OpusHead { + channels: 1, + pre_skip: 0, + input_sample_rate: 48_000, + output_gain: 0, + mapping_family: 255, + streams: usize::MAX, + coupled_streams: 0, + mapping: vec![0], + }; + assert!(crate::Decoder::from_head(head, 48_000).is_err()); + } } diff --git a/crates/opus/src/multistream.rs b/crates/opus/src/multistream.rs index a41ba10c3..f47c38179 100644 --- a/crates/opus/src/multistream.rs +++ b/crates/opus/src/multistream.rs @@ -32,6 +32,7 @@ impl Decoder { /// Creates a decoder for a parsed header at `sample_rate` (8/12/16/24/48 kHz). pub fn from_head(head: OpusHead, sample_rate: u32) -> Result { + head.validate()?; let mut streams = Vec::with_capacity(head.streams); for s in 0..head.streams { let ch = if s < head.coupled_streams { 2 } else { 1 }; From a6ed3ecd14fa45e4dd3e7d5e8eede09ed73b85a1 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:05 +0200 Subject: [PATCH 022/110] security(text): bound transformed glyph masks (#2900) --- crates/text/src/render.rs | 65 +++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 940767db1..2d99030bb 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -131,35 +131,62 @@ pub struct GlyphMask { type GKey = (FaceId, u32, u32, u8, bool, bool); -fn glyph_cache() -> &'static Mutex>>> { - static C: OnceLock>>>> = OnceLock::new(); +/// No single glyph mask may consume more than this much memory. Larger glyphs are filled directly +/// into the caller's clipped destination instead of first allocating their complete bounds. +const MAX_GLYPH_MASK_BYTES: usize = 16 << 20; +/// The glyph cache is shared by every project in the process, so constrain its bytes as well as +/// its number of entries. +const MAX_GLYPH_CACHE_BYTES: usize = 64 << 20; + +#[derive(Default)] +struct GlyphCache { + entries: HashMap>>, + bytes: usize, +} + +fn glyph_cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); C.get_or_init(Default::default) } +fn glyph_mask_bounds(path: &Path) -> Option<(i32, i32, usize, usize)> { + let (x0, y0, x1, y1) = path.bounds()?; + let (left, top, right, bottom) = (x0.floor() as i64, y0.floor() as i64, x1.ceil() as i64, y1.ceil() as i64); + let (left, top) = (i32::try_from(left).ok()?, i32::try_from(top).ok()?); + let w = usize::try_from(right.saturating_sub(i64::from(left))).ok()?.max(1); + let h = usize::try_from(bottom.saturating_sub(i64::from(top))).ok()?.max(1); + let bytes = w.checked_mul(h)?.checked_mul(std::mem::size_of::())?; + (bytes <= MAX_GLYPH_MASK_BYTES).then_some((left, top, w, h)) +} + /// Rasterise glyph `g` at pixel size `px` with horizontal phase `frac` (0..1, quantised to ¼). pub fn glyph_mask(g: &Glyph, px: f32, frac: f32) -> Option> { let size_q = (px * 8.0).round().max(1.0) as u32; let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4; let key = (g.face, g.id, size_q, frac_q, g.synth_bold, g.synth_italic); - if let Some(v) = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + if let Some(v) = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()).entries.get(&key) { return v.clone(); } let px = size_q as f32 / 8.0; let fq = frac_q as f32 / 4.0; let gg = Glyph { x: 0.0, y: 0.0, size: px, ..*g }; let path = glyph_path(&gg, &[1.0, 0.0, 0.0, 1.0, fq, 0.0]); - let v = path.bounds().map(|(x0, y0, x1, y1)| { - let left = x0.floor() as i32; - let top = y0.floor() as i32; - let w = (x1.ceil() as i32 - left).max(1) as usize; - let h = (y1.ceil() as i32 - top).max(1) as usize; - Arc::new(GlyphMask { mask: fill(&path, w, h, -left as f32, -top as f32), left, top }) - }); + let Some((left, top, w, h)) = glyph_mask_bounds(&path) else { + // Do not cache an oversized rejection: callers render it directly into their bounded + // output, and retaining attacker-selected keys would itself become a cache DoS. + return None; + }; + let v = Some(Arc::new(GlyphMask { mask: fill(&path, w, h, -left as f32, -top as f32), left, top })); + let bytes = w * h * std::mem::size_of::(); let mut m = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()); - if m.len() > 16_384 { - m.clear(); + if m.entries.len() > 16_384 || m.bytes.checked_add(bytes).is_none_or(|n| n > MAX_GLYPH_CACHE_BYTES) { + m.entries.clear(); + m.bytes = 0; } - m.insert(key, v.clone()); + if let Some(old) = m.entries.insert(key, v.clone()) { + m.bytes = m.bytes.saturating_sub(old.as_ref().map_or(0, |g| g.mask.a.len() * std::mem::size_of::())); + } + m.bytes = m.bytes.saturating_add(bytes); v } @@ -223,6 +250,12 @@ pub fn draw_run(l: &Layout, m: &Xform, out: &mut Mask, origin: (i32, i32), run: let xi = x.floor(); if let Some(gm) = glyph_mask(g, px, x - xi) { out.add(&gm.mask, xi as i32 + gm.left - ox, y.round() as i32 + gm.top - oy); + } else { + // A huge glyph's mask would be mostly clipped away. Rasterise its outline straight + // into `out`, whose allocation is already bounded by the caller's canvas. + let gm = compose(m, &[1.0, 0.0, 0.0, 1.0, g.x, g.y]); + let p = glyph_path(g, &gm); + fill_into(&p, out, -ox as f32, -oy as f32); } } if !l.underlines.is_empty() { @@ -280,6 +313,12 @@ mod tests { assert!(g.top < 0, "rises above the baseline"); } + #[test] + fn oversized_glyph_mask_is_rejected_before_allocation() { + assert!(glyph_mask_bounds(&Path::rect(0.0, 0.0, 100_000.0, 100_000.0)).is_none()); + assert!(glyph_mask_bounds(&Path::rect(0.0, 0.0, 32.0, 32.0)).is_some()); + } + #[test] fn cached_and_outline_paths_agree() { let l = layout("Title 42", &TextStyle { size: 48.0, ..Default::default() }, &ParagraphStyle::default()); From 66da0e527026d627c68546655641f2f11f08e4b6 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:09 +0200 Subject: [PATCH 023/110] security(avi): bound OpenDML index work (#2925) --- crates/avi/src/lib.rs | 43 +++++++++++++++++++++++++++++++---------- crates/avi/src/tests.rs | 15 ++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/avi/src/lib.rs b/crates/avi/src/lib.rs index 0294fdfea..e11bc7b5f 100644 --- a/crates/avi/src/lib.rs +++ b/crates/avi/src/lib.rs @@ -21,6 +21,8 @@ mod tests; pub use source::ByteSource; +use std::collections::HashSet; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { /// Not an AVI file (no `RIFF` … `AVI ` header). @@ -175,6 +177,9 @@ pub struct OpenOptions { /// Headers larger than this are refused (a real `hdrl` is a few kilobytes). const MAX_HEADER: u64 = 16 << 20; +/// Total OpenDML standard-index bytes accepted while opening one file. This bounds hostile +/// repeated index references without constraining practical large-file indexes. +const MAX_ODML_INDEX_BYTES: u64 = 64 << 20; /// Streams beyond this are ignored. const MAX_STREAMS: usize = 64; /// `LIST rec ` inside `movi` nests once; deeper is refused. @@ -443,34 +448,52 @@ fn super_index(b: &[u8]) -> Vec { /// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream /// that has data lacks one or none could be read. fn read_odml(src: &S, streams: &mut [Stream]) -> bool { - let limit = src.len() / 8; - let mut total = 0u64; + let entry_limit = src.len() / 8; + let mut entries = 0u64; + let mut index_bytes = 0u64; + let mut seen = HashSet::new(); for s in streams.iter_mut() { s.chunks.clear(); s.keyframes_known = true; for &at in &s.super_index { + // A super index can repeat an offset (even across streams). Read and process a + // standard index once, rather than multiplying its work and allocations. + if !seen.insert(at) { + continue; + } let Ok((id, size, data)) = header_at(src, at) else { continue }; if &id[..2] != b"ix" { continue; } - let Ok(b) = read(src, data, size.min(MAX_HEADER * 8)) else { continue }; - let (longs, subtype, itype) = (usize::from(le16(&b, 0)), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0)); + // Validate the fixed header before allocating the variable entry body. + if size < 24 || index_bytes.checked_add(24).is_none_or(|n| n > MAX_ODML_INDEX_BYTES) { + return false; + } + let Ok(header) = read(src, data, 24) else { continue }; + index_bytes += 24; + let (longs, subtype, itype) = (usize::from(le16(&header, 0)), header.get(2).copied().unwrap_or(0), header.get(3).copied().unwrap_or(0)); // AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD) if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) { continue; } - let base = le64(&b, 12); let step = longs * 4; - let n = (le32(&b, 4) as usize).min(b.len().saturating_sub(24) / step); + let n = u64::from(le32(&header, 4)).min(size.saturating_sub(24) / step as u64); + let body = n.saturating_mul(step as u64); + let Some(next_bytes) = index_bytes.checked_add(body) else { return false }; + let Some(next_entries) = entries.checked_add(n) else { return false }; + if next_bytes > MAX_ODML_INDEX_BYTES || next_entries > entry_limit { + return false; + } + let Ok(b) = read(src, data, 24 + body) else { continue }; + index_bytes = next_bytes; + entries = next_entries; + let base = le64(&b, 12); + let n = usize::try_from(n).unwrap_or(0); for k in 0..n { let e = 24 + k * step; let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4)); let size = raw & 0x7FFF_FFFF; let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { continue }; - total += 1; - if total > limit { - return false; - } s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 }); } } diff --git a/crates/avi/src/tests.rs b/crates/avi/src/tests.rs index 248364d6f..42e3dce9f 100644 --- a/crates/avi/src/tests.rs +++ b/crates/avi/src/tests.rs @@ -267,3 +267,18 @@ fn opendml_standard_indexes() { } } } + +#[test] +fn opendml_repeated_index_offsets_are_read_once() { + let (_, at) = avi_odml([0, 0]); + let (file, _) = avi_odml(at); + let mut streams = open(&file).unwrap().streams; + // A malicious super index repeats each valid standard-index address. It must not multiply + // its chunks, allocations, or traversal work. + for (stream, &index) in streams.iter_mut().zip(&at) { + stream.super_index = vec![index; 100]; + } + assert!(read_odml(&file, &mut streams)); + assert_eq!(streams[0].chunks.len(), 3); + assert_eq!(streams[1].chunks.len(), 2); +} From 0b99bafcc46af6707fb61fe53d180bc50df8edc7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:53:14 +0200 Subject: [PATCH 024/110] Project panel: a clip dropped on New Item makes a sequence from it (fixes #2855) (#2940) --- crates/ui-egui/src/panels/mod.rs | 8 +++ crates/ui-egui/src/panels/project.rs | 11 +++++ crates/ui-egui/tests/project_panel_ui.rs | 62 ++++++++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ff18ab0f2..0f21158fc 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -180,6 +180,14 @@ pub fn dragged_project_item(ui: &egui::Ui) -> Option { _ => None, } } +/// An item dragged from the Project panel (or the Media Browser), but not a Source Monitor drag, +/// whose In/Out range and video/audio choice only a timeline drop keeps. +pub fn dragged_panel_item(ui: &egui::Ui) -> Option { + match payload(ui) { + Some(DragPayload::Item(i)) => Some(i), + _ => None, + } +} /// The items a drag from the Project panel carries: the whole selection when the dragged item is /// one of several selected, otherwise just the dragged item. pub fn dragged_selection(app: &FilmcraftApp, item: ItemId) -> Vec { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index e6d95c0e8..6e3487e8d 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -630,6 +630,17 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action } icons::paint(ui.painter(), r.shrink(5.0), icon, t.icon); if id == "new-item" { + // a clip dropped here makes a sequence that matches it, as in Premiere (#2855) + if let Some(item) = crate::panels::dragged_panel_item(ui) + && ui.rect_contains_pointer(r) + { + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, app.tokens.accent), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + let items: Vec = crate::panels::dragged_selection(app, item).iter().map(|i| i.0).collect(); + actions.push(("file.newSequenceFromClip".into(), json!({"items": items}))); + crate::panels::clear_drag(ui); + } + } egui::Popup::menu(&resp).show(|ui| new_item_menu(app, ui, pre, actions)); } else if resp.clicked() { match id { diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index 8fdbb9f0c..10ca52076 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -1181,3 +1181,65 @@ fn escape_takes_back_a_media_browser_drag() { assert_eq!(d.app().session.project.to_json(), before, "nothing was placed on the Timeline"); let _ = std::fs::remove_dir_all(&dir); } + +/// A clip dragged onto the New Item button makes a sequence from it, as File ▸ New ▸ Sequence +/// From Clip does (#2855): named after the clip, with its frame size and rate, the clip on the +/// timeline and the sequence open. One of several selected items brings the whole selection. +#[test] +fn dragging_clips_onto_new_item_makes_a_matching_sequence() { + let mut d = Driver::demo(); + let (bin, items) = d.footage(); + assert!(items.len() >= 3); + d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}})); + let card = |i: u64| format!("project.item.{i}"); + d.wait_for(&card(items[0])); + let sequences = |d: &mut Driver| d.app().session.project.items.values().filter(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))).count(); + let clips = |d: &mut Driver| d.app().session.active_sequence().map(|q| q.video_tracks.iter().map(|t| t.items.len()).sum::()).unwrap_or(0); + let drop = json!({"id": "project.button.new-item"}); + // footage that differs from a new sequence's defaults, so matching it shows + if let Some(filmcraft_project::ItemKind::Media(m)) = std::sync::Arc::make_mut(&mut d.app().session.project).item_mut(ItemId(items[0])).map(|i| &mut i.kind) + && let Some(v) = m.info.video.as_mut() + { + (v.width, v.height, v.frame_rate) = (1280, 720, filmcraft_time::FrameRate::FPS_25); + } + let def = filmcraft_project::SequenceSettings::default(); + assert_ne!((def.width, def.height, def.frame_rate), (1280, 720, filmcraft_time::FrameRate::FPS_25)); + let n0 = sequences(&mut d); + + // a drop on another footer button, or a Source Monitor drag, makes nothing + d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": {"id": "project.button.file.newBin"}, "steps": 16})); + d.frames(4); + assert_eq!(sequences(&mut d), n0, "New Bin is not a drop target"); + d.exec("source.open", json!({"item": items[0]})); + d.wait_for("source.drag.both"); + d.ok("ui.drag", json!({"from": {"id": "source.drag.both"}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(sequences(&mut d), n0, "a Source Monitor drag is not taken"); + + // an item that is not selected: a sequence of that clip alone + d.exec("project.select", json!({"items": [items[2]]})); + let steps = d.app().session.history.undo.len(); + d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(sequences(&mut d), n0 + 1, "the drop makes one sequence"); + assert!(!d.has("project.newItem.file.newSequence"), "the drop does not open the New Item menu"); + let app = d.app(); + let clip = app.session.project.item(ItemId(items[0])).unwrap().clone(); + let filmcraft_project::ItemKind::Media(m) = &clip.kind else { panic!("footage is media") }; + let v = m.info.video.clone().expect("footage has video"); + let q = app.session.active_sequence().expect("the new sequence is active"); + let seq = app.session.state.active_sequence.unwrap(); + assert_eq!(app.session.project.item(seq).unwrap().name, clip.name, "named after the clip"); + assert_eq!((q.settings.width, q.settings.height, q.settings.frame_rate), (v.width, v.height, v.frame_rate), "the clip's frame size and rate"); + assert_eq!(q.video_tracks[0].items.first().map(|c| c.item), Some(ItemId(items[0])), "the clip is on the timeline"); + assert_eq!(app.session.history.undo.len(), steps + 1, "one undo step"); + assert_eq!(clips(&mut d), 1, "an unselected item goes alone"); + + // two of the selection: both, back to back, in one sequence + d.exec("project.select", json!({"items": [items[0], items[1]]})); + d.frames(2); + d.ok("ui.drag", json!({"from": {"id": card(items[1])}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(sequences(&mut d), n0 + 2); + assert_eq!(clips(&mut d), 2, "the whole selection is placed"); +} From 1c40c482891f8709e36d0dc7c1d4370992548984 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:19 +0200 Subject: [PATCH 025/110] security(mxf): share resolved descriptor metadata (#2949) * security(mxf): share resolved descriptor metadata * mxf: keep every source-package track in the no-material-package fallback Two packages may reuse a track id and number; comparing only those dropped a real track. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/mxf/src/file.rs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/mxf/src/file.rs b/crates/mxf/src/file.rs index 75dd27991..3394b657c 100644 --- a/crates/mxf/src/file.rs +++ b/crates/mxf/src/file.rs @@ -1,6 +1,7 @@ //! Opening an MXF file: KLV walk, partitions, metadata resolution, sample tables. use std::collections::HashMap; +use std::sync::Arc; use crate::essence::{Codec, PictureInfo, SoundFormat, SoundInfo, decode_pcm, identify_picture, sniff_picture}; use crate::index::{IndexEntry, IndexSegment}; @@ -544,8 +545,8 @@ struct TrackRef { origin: i64, start_position: i64, duration: Option, - descriptor: Option, - sub_descriptors: Vec, + descriptor: Option>, + sub_descriptors: Vec>, body_sid: u32, index_sid: u32, } @@ -567,9 +568,13 @@ fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, } let material = md.of_kind(st::MATERIAL_PACKAGE).next(); let mut out = Vec::new(); + let mut descriptor_cache: HashMap<[u8; 16], Arc> = HashMap::new(); + let intern_descriptor = |set: &Set, cache: &mut HashMap<[u8; 16], Arc>| { + if set.instance_uid == [0; 16] { Arc::new(set.clone()) } else { cache.entry(set.instance_uid).or_insert_with(|| Arc::new(set.clone())).clone() } + }; let mut timecode = material.and_then(|m| package_timecode(md, m)); let name = material.and_then(|m| m.string(tag::PACKAGE_NAME)); - let file_track = |pkg: &Set, kind: Option, id: Option, start: i64, duration: Option, name: Option| -> Option { + let mut file_track = |pkg: &Set, kind: Option, id: Option, start: i64, duration: Option, name: Option| -> Option { let umid = umid_of(pkg, tag::PACKAGE_UID).unwrap_or([0; 32]); for r in pkg.references(tag::PACKAGE_TRACKS) { let Some(t) = md.get(&r) else { continue }; @@ -588,10 +593,16 @@ fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, continue; } let desc = descriptor_for(md, pkg, tid, tk); - let subs: Vec = desc + let descriptor = desc.map(|d| intern_descriptor(d, &mut descriptor_cache)); + let subs: Vec> = desc .and_then(|d| d.dynamic_item(&SUB_DESCRIPTORS_ITEM)) .map(|v| { - crate::klv::batch_of(v).into_iter().filter_map(|b| b.get(..16)).filter_map(|b| md.get(&b.try_into().unwrap_or([0; 16]))).cloned().collect() + crate::klv::batch_of(v) + .into_iter() + .filter_map(|b| b.get(..16)) + .filter_map(|b| md.get(&b.try_into().unwrap_or([0; 16]))) + .map(|s| intern_descriptor(s, &mut descriptor_cache)) + .collect() }) .unwrap_or_default(); let (body_sid, index_sid) = sids.get(&umid).copied().unwrap_or((0, 0)); @@ -604,7 +615,7 @@ fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, origin: t.i(tag::ORIGIN).unwrap_or(0), start_position: start, duration, - descriptor: desc.cloned(), + descriptor, sub_descriptors: subs, body_sid, index_sid, @@ -807,7 +818,7 @@ fn build_track( }; match r.kind { TrackKind::Picture => { - let pi = desc.map(picture_info).unwrap_or_default(); + let pi = desc.map(|d| picture_info(d)).unwrap_or_default(); let mpeg_desc = desc.is_some_and(|d| d.kind() == Some(st::MPEG2_VIDEO_DESCRIPTOR)); let mut codec = identify_picture(pi.picture_coding, container, mpeg_desc); if r.sub_descriptors.iter().any(|s| s.kind() == Some(st::AVC_SUBDESCRIPTOR)) && !matches!(codec, Codec::Avc { .. }) { @@ -828,7 +839,7 @@ fn build_track( build_pictures(src, &mut t, elements, index, warnings)?; } TrackKind::Sound => { - let si = desc.map(sound_info).unwrap_or(SoundInfo { channels: 1, bits: 16, ..Default::default() }); + let si = desc.map(|d| sound_info(d)).unwrap_or(SoundInfo { channels: 1, bits: 16, ..Default::default() }); let d10 = container.is_some_and(|c| c.item_starts_with(&[0x0D, 0x01, 0x03, 0x01, 0x02, 0x01])); let aes_element = d10 || key4[0] == 0x06 && key4[2] == 0x10; let pcm_desc = desc.is_some_and(|d| matches!(d.kind(), Some(st::AES3_DESCRIPTOR) | Some(st::WAVE_DESCRIPTOR))); From 875eec90e958356aa0c0a89e89e8bcb2b1a98324 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:24 +0200 Subject: [PATCH 026/110] security(render): bound expanded frame plans (#2942) * security(render): bound expanded frame plans * render: count plan layers as they are pushed instead of re-summing every step Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/render/src/plan.rs | 96 ++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 16 deletions(-) diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 120785ded..c40b534d9 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -27,6 +27,10 @@ use filmcraft_time::Tick; use crate::gpufx::FxOp; use crate::{Blend, RenderOptions, SourceProvider, motion_matrix, output_size}; +/// A GPU frame plan retains every resolved source frame. Nested sequences may share the same +/// child sequence many times, so recursion depth alone does not bound this expansion. +const MAX_PLAN_LAYERS: usize = 4096; + /// One layer for the GPU, bottom to top. #[derive(Clone)] pub struct PlanLayer { @@ -93,33 +97,63 @@ pub enum PlanStep { } #[derive(Default)] -struct PlanStack(Vec); +struct PlanStack { + steps: Vec, + exhausted: bool, + /// Layers held in `steps`, kept as they are pushed so the cap check stays O(1). + layers: usize, +} impl PlanStack { - fn push(&mut self, layer: PlanLayer) { - if let Some(PlanStep::Layers(layers)) = self.0.last_mut() { - layers.push(layer); - } else { - self.0.push(PlanStep::Layers(vec![layer])); - } + fn layer_count(&self) -> usize { + self.layers } - fn into_layers(self, width: usize, height: usize) -> Vec { + fn push(&mut self, layer: PlanLayer) { + if self.exhausted { + return; + } + if self.layer_count() >= MAX_PLAN_LAYERS { + self.exhausted = true; + return; + } + if let Some(PlanStep::Layers(layers)) = self.steps.last_mut() { + layers.push(layer); + } else { + self.steps.push(PlanStep::Layers(vec![layer])); + } + self.layers = self.layers.saturating_add(1); + } + + fn push_transition(&mut self, inputs: [Vec; 2], effect: filmcraft_project::EffectInstance, progress: f32, scale: f32) { + let added: usize = inputs.iter().map(Vec::len).sum(); + if self.exhausted || self.layer_count().saturating_add(added) > MAX_PLAN_LAYERS { + self.exhausted = true; + return; + } + self.layers = self.layers.saturating_add(added); + self.steps.push(PlanStep::Transition { inputs, effect, progress, scale }); + } + + fn into_layers(self, width: usize, height: usize) -> Option> { + if self.exhausted { + return None; + } match self.finish(width, height) { - FramePlan::Layers { layers, .. } => layers, - plan => vec![PlanLayer::new(cpu_frame(execute_cpu(&plan)), Affine::IDENTITY, 1.0, Blend::Normal)], + FramePlan::Layers { layers, .. } => Some(layers), + plan => Some(vec![PlanLayer::new(cpu_frame(execute_cpu(&plan)), Affine::IDENTITY, 1.0, Blend::Normal)]), } } fn finish(mut self, width: usize, height: usize) -> FramePlan { - if self.0.len() <= 1 && self.0.first().is_none_or(|s| matches!(s, PlanStep::Layers(_))) { - let layers = match self.0.pop() { + if self.steps.len() <= 1 && self.steps.first().is_none_or(|s| matches!(s, PlanStep::Layers(_))) { + let layers = match self.steps.pop() { Some(PlanStep::Layers(layers)) => layers, _ => Vec::new(), }; FramePlan::Layers { width, height, layers } } else { - FramePlan::Composite { width, height, steps: self.0 } + FramePlan::Composite { width, height, steps: self.steps } } } } @@ -200,6 +234,9 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption } let mut layers = PlanStack::default(); push_tracks(project, seq, t, opts, sources, 0, &mut layers); + if layers.exhausted { + return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources)); + } if opts.captions { for o in crate::caption_overlays(seq, t, w, h) { layers.push(PlanLayer::new( @@ -210,6 +247,9 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption )); } } + if layers.exhausted { + return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources)); + } layers.finish(w, h) } @@ -258,6 +298,9 @@ pub(crate) fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool { fn push_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider, nest: u32, layers: &mut PlanStack) { let (w, h) = output_size(seq, opts.scale); for tr in &seq.video_tracks { + if layers.exhausted { + return; + } if !tr.enabled { continue; } @@ -273,7 +316,11 @@ fn push_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, if let Some(item) = item { let mut input = PlanStack::default(); push_item(project, seq, item, t, opts, sources, 1.0, Some(Blend::Normal), nest, &mut input); - *slot = input.into_layers(w, h); + let Some(input_layers) = input.into_layers(w, h) else { + layers.exhausted = true; + return; + }; + *slot = input_layers; } } let progress = if trn.reverse { @@ -282,7 +329,7 @@ fn push_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, } else { p }; - layers.0.push(PlanStep::Transition { inputs, effect: trn.effect.clone(), progress, scale: opts.scale }); + layers.push_transition(inputs, trn.effect.clone(), progress, opts.scale); } "dip_to_black" | "dip_to_white" => { let col = if trn.effect.effect == "dip_to_black" { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }; @@ -345,6 +392,9 @@ fn push_item( nest: u32, out: &mut PlanStack, ) { + if out.exhausted { + return; + } // frame time (`ft`) vs. effect time (`mt`): they differ inside a frame hold without Hold Filters let ft = item.source_time_at(t); let mt = item.effect_time_at(t); @@ -383,6 +433,9 @@ fn push_item( && nest_is_plain(project, seq, nested, ft) { push_tracks(project, nested, ft, opts, sources, nest + 1, out); + if out.exhausted { + return; + } let (w, h) = output_size(nested, opts.scale); for o in crate::caption_overlays(nested, ft, w, h) { out.push(PlanLayer::new( @@ -563,9 +616,20 @@ mod tests { scale: 1.0, }]; let expected = execute_cpu(&FramePlan::Composite { width: 1, height: 1, steps: steps.clone() }); - let layers = PlanStack(steps).into_layers(1, 1); + let layers = PlanStack { steps, exhausted: false, layers: 1 }.into_layers(1, 1).unwrap(); let got = execute_cpu(&FramePlan::Layers { width: 1, height: 1, layers }); assert_eq!(got.px, expected.px); assert!(got.px.iter().any(|v| *v != 0.0)); } + + #[test] + fn layer_plan_has_a_global_budget() { + let frame = Arc::new(VideoFrame::rgba_f32(1, 1, vec![0.0; 4])); + let mut stack = PlanStack::default(); + for _ in 0..=MAX_PLAN_LAYERS { + stack.push(PlanLayer::new(frame.clone(), Affine::IDENTITY, 1.0, Blend::Normal)); + } + assert!(stack.exhausted); + assert_eq!(stack.layer_count(), MAX_PLAN_LAYERS); + } } From 7f8c9921a138c1b612f0b0cfeb3337167de4b271 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:29 +0200 Subject: [PATCH 027/110] security(engine): confine stored media filesystem access (#2869) * security(engine): confine stored media filesystem access * relink: index symlinked files; skip only symlinked folders Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/project_manager.rs | 32 +++++++++++++++++----------- crates/engine/src/relink.rs | 19 ++++++++++++----- crates/engine/src/relink_tests.rs | 25 +++++++++++++++++++++- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs index 3a78fff18..53c543e80 100644 --- a/crates/engine/src/project_manager.rs +++ b/crates/engine/src/project_manager.rs @@ -137,7 +137,10 @@ pub fn run(s: &mut Session, p: &Value) -> Result { for id in ids { let Some(m) = proj.item(id).and_then(|i| i.as_media()).cloned() else { continue }; let MediaRef::File { path } = &m.media else { continue }; - let size = s.services.file_size(path).unwrap_or(0); + // Project files carry stored paths; use only the media pool's resolved, policy-approved + // pathname for filesystem I/O. Keep the stored path in the project itself. + let source_path = s.media.follow(path).map_err(|why| EngineError::Other(format!("{path} is not opened: {why}")))?; + let size = s.services.file_size(&source_path).unwrap_or(0); original_bytes += size; let file = Path::new(path); let stem = file.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| format!("media-{}", id.0)); @@ -158,20 +161,23 @@ pub fn run(s: &mut Session, p: &Value) -> Result { }; taken.push(out.clone()); let proxy = match (&m.proxy, include_proxies) { - (Some(MediaRef::File { path: pp }), true) if s.services.file_size(pp).is_ok() => { - let pf = Path::new(pp); - let ps = pf.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let pe = pf.extension().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let o = unique_path(&dest_dir.join("Proxies"), &ps, "", &pe, &taken).to_string_lossy().into_owned(); - taken.push(o.clone()); - let b = s.services.file_size(pp).unwrap_or(0); - original_bytes += b; - result_bytes += b; - Some((pp.clone(), o)) - } + (Some(MediaRef::File { path: pp }), true) => match s.media.follow(pp) { + Ok(resolved_proxy) if s.services.file_size(&resolved_proxy).is_ok() => { + let pf = Path::new(&resolved_proxy); + let ps = pf.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let pe = pf.extension().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let o = unique_path(&dest_dir.join("Proxies"), &ps, "", &pe, &taken).to_string_lossy().into_owned(); + taken.push(o.clone()); + let b = s.services.file_size(&resolved_proxy).unwrap_or(0); + original_bytes += b; + result_bytes += b; + Some((resolved_proxy, o)) + } + _ => None, + }, _ => None, }; - work.push(Work { item: id, src_path: path.clone(), out, range, proxy }); + work.push(Work { item: id, src_path: source_path, out, range, proxy }); } let previews = s.path.as_deref().map(crate::previews::dir_for_project).filter(|d| include_previews && d.is_dir()); if let Some(d) = &previews { diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index c3b6b3879..7a75826e9 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -489,9 +489,14 @@ pub fn index_folder(root: &Path, limit: usize) -> BTreeMap> if name.starts_with('.') { continue; } - if p.is_dir() { + let Ok(file_type) = e.file_type() else { continue }; + // A relink search must stay within the folder explicitly chosen by the user. In + // particular, following a directory symlink can make a small loop defeat the file + // budget and walk an unrelated tree. Symlinked files are still candidates. + let is_file = if file_type.is_symlink() { std::fs::metadata(&p).is_ok_and(|m| m.is_file()) } else { file_type.is_file() }; + if file_type.is_dir() { queue.push_back(p); - } else { + } else if is_file { out.entry(name.to_ascii_lowercase()).or_default().push(p); n += 1; if n >= limit { @@ -564,7 +569,11 @@ pub fn make_offline(s: &mut Session, p: &Value) -> Result { let mut deleted = Vec::new(); if delete { for (_, path) in &files { - if std::fs::remove_file(path).is_ok() { + // Stored paths in an opened document are not authority to delete arbitrary files. + // Delete only the resolved path that the media pool permits opening. + if let Ok(path) = s.media.follow(path) + && std::fs::remove_file(&path).is_ok() + { deleted.push(path.clone()); } } @@ -583,14 +592,14 @@ fn status_json(s: &Session, id: ItemId) -> Option { let status = match &path { None => "generated", Some(_) if m.offline => "offline", - Some(p) if s.services.file_size(p).is_err() => "missing", + Some(p) if s.media.follow(p).ok().is_none_or(|file| s.services.file_size(&file).is_err()) => "missing", Some(_) => match s.media.offline_status(id) { Some(o) if o.reason == filmcraft_render::offline::OfflineReason::Unreadable => "unreadable", _ => "online", }, }; let proxy = match &m.proxy { - Some(MediaRef::File { path }) => json!({"path": path, "online": s.services.file_size(path).is_ok()}), + Some(MediaRef::File { path }) => json!({"path": path, "online": s.media.follow(path).is_ok_and(|file| s.services.file_size(&file).is_ok())}), _ => Value::Null, }; Some(json!({ diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index 33208c544..180629a2a 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -9,7 +9,7 @@ use serde_json::json; use crate::Session; use crate::media_test_util::{frame_rgba, make_movie, psnr, session_with, tmp_dir}; -use crate::relink::{apply_remap, derive_remap}; +use crate::relink::{apply_remap, derive_remap, index_folder}; const W: u32 = 160; const H: u32 = 90; @@ -163,6 +163,29 @@ fn make_offline_shows_the_slate_until_relinked() { let _ = std::fs::remove_dir_all(&root); } +#[cfg(unix)] +#[test] +fn relink_search_does_not_follow_directory_symlinks() { + use std::os::unix::fs::symlink; + + let root = tmp_dir("relink-no-symlink-walk"); + let inside = root.join("inside"); + let outside = root.join("outside"); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(inside.join("kept.mov"), b"kept").unwrap(); + std::fs::write(outside.join("outside.mov"), b"outside").unwrap(); + symlink(&outside, inside.join("loop")).unwrap(); + symlink(outside.join("outside.mov"), inside.join("linked.mov")).unwrap(); + + let index = index_folder(&inside, 20_000); + assert!(index.contains_key("kept.mov")); + assert!(!index.contains_key("outside.mov")); + // a symlinked file is still media the user can relink to + assert!(index.contains_key("linked.mov")); + let _ = std::fs::remove_dir_all(&root); +} + // Regression test for issue #110: after `file.import` re-imports a file at a path the project // already listed as offline, `s.offline.missing` must drop that item so the slate and the // "Media missing" badge clear without reopening the project. Before the fix (the patch in From b12336cf730265bb1122bda68dac45c31e701705 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:53:33 -0400 Subject: [PATCH 028/110] ci(deps): bump Swatinem/rust-cache (#2971) Bumps the actions group with 1 update: [Swatinem/rust-cache](https://github.com/swatinem/rust-cache). Updates `Swatinem/rust-cache` from 2.9.2 to 2.9.3 - [Release notes](https://github.com/swatinem/rust-cache/releases) - [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md) - [Commits](https://github.com/swatinem/rust-cache/compare/6323deb102c322ba6fcbdcafc7e3dddab59af2b6...d1527c8f67ef9af357290475d4af4b0fab8f12e0) --- updated-dependencies: - dependency-name: Swatinem/rust-cache dependency-version: 2.9.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1331de2b..be6e31cfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: --target wasm32-unknown-unknown rustup default stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' }} From 70d0260b6e3fee26e60b96f00753623bd404737f Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:38 +0200 Subject: [PATCH 029/110] security(dnx): validate fields before frame allocation (#2914) --- crates/dnx/src/decode.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/dnx/src/decode.rs b/crates/dnx/src/decode.rs index 22f9f542b..ede98b0b3 100644 --- a/crates/dnx/src/decode.rs +++ b/crates/dnx/src/decode.rs @@ -54,9 +54,11 @@ pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result { } else { (decode_unit(&h1, &data[..off], opts), decode_unit(&h2, &data[off..], opts)) }; + let (top, bottom) = if h1.field == FieldCode::Field2 { (p2?, p1?) } else { (p1?, p2?) }; + // Both field payloads must be valid before committing the full-frame + // allocation; a damaged header alone must not allocate an output picture. let mut f = Frame::new_like(&h1, h1.width, h1.lines * 2); f.interlace = Interlace::TopFieldFirst; - let (top, bottom) = if h1.field == FieldCode::Field2 { (p2?, p1?) } else { (p1?, p2?) }; top.copy_into(&mut f, 0, 2); bottom.copy_into(&mut f, 1, 2); Ok(f) From 8119b36c21763466892d5bb78aa2bcb648b874f8 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:42 +0200 Subject: [PATCH 030/110] security(mpeg2v): index picture extensions linearly (#2904) --- crates/mpeg2v/src/lib.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/mpeg2v/src/lib.rs b/crates/mpeg2v/src/lib.rs index 6d8cb0497..55c1ab9ad 100644 --- a/crates/mpeg2v/src/lib.rs +++ b/crates/mpeg2v/src/lib.rs @@ -338,11 +338,16 @@ pub fn access_units(es: &[u8]) -> Vec> { } PICTURE_START => { // picture_structure from the picture coding extension that follows - let structure = codes[k + 1..] + let structure = codes .iter() - .take_while(|c| c.1 == EXTENSION || c.1 == headers::USER_DATA) - .find_map(|&(p, c)| { - let end = codes.iter().find(|x| x.0 > p).map_or(es.len(), |x| x.0); + .enumerate() + .skip(k + 1) + .take_while(|(_, c)| c.1 == EXTENSION || c.1 == headers::USER_DATA) + .find_map(|(next, &(p, c))| { + // `next` is this unit's position in `codes`, so its successor gives the + // payload end directly. Searching `codes` from the beginning here made a + // stream of short picture/extension pairs quadratic to index. + let end = codes.get(next + 1).map_or(es.len(), |x| x.0); (c == EXTENSION).then(|| Extension::parse(&es[(p + 4).min(end)..end]).ok()).flatten() }) .and_then(|e| match e { From 21b56ac0904eef282dc6df71401a95da5a466b6e Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:47 +0200 Subject: [PATCH 031/110] security(apv): validate tile payload before allocation (#2916) --- crates/apv/src/decode.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/apv/src/decode.rs b/crates/apv/src/decode.rs index 997aaa0b4..910ea5207 100644 --- a/crates/apv/src/decode.rs +++ b/crates/apv/src/decode.rs @@ -377,7 +377,20 @@ fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[ let blocks_x = blk_w / 8; let blocks_y = blk_h / 8; - let mut comp_buf = vec![0u16; tw_c * th_c]; + // Every coded 8x8 block needs at least one bit for its DC code. Validate + // that the component can describe every required block before allocating + // its complete reconstructed plane. + let block_count = (num_mb_cols as usize) + .checked_mul(num_mb_rows as usize) + .and_then(|n| n.checked_mul(blocks_x)) + .and_then(|n| n.checked_mul(blocks_y)) + .ok_or(Error::CorruptTile(tile_idx))?; + if comp_bytes.len() < block_count.div_ceil(8) { + return Err(Error::CorruptTile(tile_idx)); + } + let samples = tw_c.checked_mul(th_c).ok_or(Error::CorruptTile(tile_idx))?; + + let mut comp_buf = vec![0u16; samples]; let mut r = BitReader::new(comp_bytes); let mut prev_dc: i32 = 0; From 7e980f31c9e34ec5cc5b8802c2b1ca7661a311bb Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:52 +0200 Subject: [PATCH 032/110] security(project): validate graphic templates in linear time (#2886) --- crates/project/src/gtemplate.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/project/src/gtemplate.rs b/crates/project/src/gtemplate.rs index be225a8b6..082cf18b6 100644 --- a/crates/project/src/gtemplate.rs +++ b/crates/project/src/gtemplate.rs @@ -35,6 +35,8 @@ //! The built-in templates ([`builtin_templates`]) are original designs made for FilmCraft in code //! (no image, font or template files), under the project licence. +use std::collections::{HashMap, HashSet}; + use filmcraft_geom::Vec2; use filmcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; @@ -278,24 +280,22 @@ impl GraphicsTemplate { if self.canvas[0] == 0 || self.canvas[1] == 0 || self.canvas[0] > 16384 || self.canvas[1] > 16384 { return bad(format!("bad canvas size {:?}", self.canvas)); } - let mut uids = Vec::new(); + let mut layers = HashMap::with_capacity(self.layers.len()); for (i, l) in self.layers.iter().enumerate() { if !graphic::is_layer(l) { return bad(format!("layer {i} is `{}`, not a graphic layer", l.effect)); } let uid = layer_uid(l); - if uid == 0 || uids.contains(&uid) { + if uid == 0 || layers.insert(uid, l).is_some() { return bad(format!("layer {i} has no unique uid")); } - uids.push(uid); } - let mut ids = Vec::new(); + let mut ids = HashSet::with_capacity(self.controls.len()); for c in &self.controls { - if c.id.is_empty() || ids.contains(&c.id) { + if c.id.is_empty() || !ids.insert(c.id.as_str()) { return bad(format!("control `{}` has no unique id", c.name)); } - ids.push(c.id.clone()); - let Some(l) = self.layers.iter().find(|l| layer_uid(l) == c.layer) else { + let Some(l) = layers.get(&c.layer) else { return bad(format!("control `{}` points at a missing layer", c.name)); }; if c.param != "enabled" && !l.params.contains_key(&c.param) { From 16d93a8c3d2164d80b8913eac7f5a0fb2c53a360 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:53:57 +0200 Subject: [PATCH 033/110] security(videotoolbox): bound reorder buffering (#2908) --- crates/platform/src/videotoolbox.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/platform/src/videotoolbox.rs b/crates/platform/src/videotoolbox.rs index 2ac6723ca..b1c04740b 100644 --- a/crates/platform/src/videotoolbox.rs +++ b/crates/platform/src/videotoolbox.rs @@ -48,6 +48,16 @@ const NV16_VIDEO: u32 = u32::from_be_bytes(*b"422v"); const NV16_FULL: u32 = u32::from_be_bytes(*b"422f"); const P210_VIDEO: u32 = u32::from_be_bytes(*b"x422"); const P210_FULL: u32 = u32::from_be_bytes(*b"xf22"); +/// H.264 and HEVC parameter parsing both enforce a maximum decoded-picture +/// buffer and reorder depth of 16 pictures. +const MAX_REORDER_DEPTH: usize = 16; + +fn validate_reorder_depth(reorder: usize) -> std::result::Result<(), String> { + if reorder > MAX_REORDER_DEPTH { + return Err("VideoToolbox reorder depth is too large".into()); + } + Ok(()) +} /// How decoded buffers are laid out and what each output picture carries. #[derive(Clone, Debug)] @@ -437,6 +447,7 @@ impl VtDecoder { /// A decoder for the stream, or why VideoToolbox does not take it (unsupported format, no /// hardware decoder, session creation failed). pub fn new(info: NalStreamInfo) -> std::result::Result { + validate_reorder_depth(info.reorder)?; let layout = layout(&info)?; if !hardware_available(info.codec) { return Err("no hardware decoder".into()); @@ -569,3 +580,14 @@ impl VideoDecoder for VtDecoder { self.info.is_disposable(sample) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reorder_depth_matches_supported_stream_limits() { + assert!(validate_reorder_depth(MAX_REORDER_DEPTH).is_ok()); + assert!(validate_reorder_depth(MAX_REORDER_DEPTH + 1).is_err()); + } +} From f554fdcb4f328f4e25560f62d06659dd352724e9 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:02 +0200 Subject: [PATCH 034/110] fix(interchange): confine embedded audio output names (#2956) --- crates/interchange/src/comp.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/interchange/src/comp.rs b/crates/interchange/src/comp.rs index 2ade96bcc..32c8698f2 100644 --- a/crates/interchange/src/comp.rs +++ b/crates/interchange/src/comp.rs @@ -698,7 +698,10 @@ pub(crate) fn to_project(doc: Document, opts: &ImportOptions, report: &mut Repor None => groups.push((k, vec![i])), } } - let doc_stem = opts.name.clone().unwrap_or_else(|| "Imported".into()); + // This becomes a directory name for embedded AAF/OMF audio. `ImportOptions::name` normally + // comes from the document filename, where backslashes are ordinary Unix filename characters; + // `resolve_path` deliberately treats them as separators, so retain only one safe component. + let doc_stem = safe_path_component(opts.name.as_deref().unwrap_or("Imported")); let mut used_paths = std::collections::HashSet::new(); for ((_, path), members) in &groups { let srcs: Vec<&Source> = members.iter().map(|&i| &doc.sources[i]).collect(); @@ -788,6 +791,13 @@ pub(crate) fn to_project(doc: Document, opts: &ImportOptions, report: &mut Repor Ok((b.finish(), extracted)) } +/// A display name used as a generated path component, never a caller-selected path fragment. +fn safe_path_component(name: &str) -> String { + let safe: String = name.chars().map(|c| if c.is_alphanumeric() || " -_.".contains(c) { c } else { '_' }).collect(); + let safe = safe.trim(); + if safe.is_empty() || matches!(safe, "." | "..") { "Imported".into() } else { safe.into() } +} + fn build_sequence( b: &mut Builder, comp: &Composition, @@ -1004,3 +1014,15 @@ pub(crate) fn ticks_to_units(t: Tick, num: i64, den: i64) -> (i64, bool) { let n = q + if r * 2 >= d { 1 } else { 0 }; (n.clamp(i64::MIN as i128, i64::MAX as i128) as i64, r == 0) } + +#[cfg(test)] +mod path_tests { + use super::safe_path_component; + + #[test] + fn generated_embedded_audio_folder_is_one_safe_component() { + for (input, expected) in [(r"..\escape", ".._escape"), ("../escape", ".._escape"), ("..", "Imported"), ("", "Imported")] { + assert_eq!(safe_path_component(input), expected, "{input:?}"); + } + } +} From f5a9a6f5e79982e919fe95b87858d933d1c127c5 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:07 +0200 Subject: [PATCH 035/110] security(codecs): deduplicate hardware parameter-set checks (#2890) --- crates/codecs/src/hw.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/codecs/src/hw.rs b/crates/codecs/src/hw.rs index d14545bb2..0ad34e5e5 100644 --- a/crates/codecs/src/hw.rs +++ b/crates/codecs/src/hw.rs @@ -8,6 +8,7 @@ //! decode, and a hardware decoder that fails mid-stream switches to //! [`crate::software_video_decoder`], so registering one never makes a file undecodable. +use std::collections::HashSet; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use filmcraft_isobmff::{CodecConfig, SampleEntry}; @@ -175,10 +176,14 @@ impl StreamInfo { /// with (parameter sets, a sequence header, a key frame of another format). pub fn parameters_changed(&self, sample: &[u8]) -> bool { match self { - Self::Nal(i) => i.nals(sample).into_iter().any(|n| { - n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) - && !i.parameter_sets.iter().any(|p| without_trailing_zeros(p) == without_trailing_zeros(n)) - }), + Self::Nal(i) => { + let configured: HashSet<&[u8]> = i.parameter_sets.iter().map(|p| without_trailing_zeros(p)).collect(); + let mut checked = HashSet::new(); + i.nals(sample).into_iter().any(|n| { + let n = without_trailing_zeros(n); + n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) && checked.insert(n) && !configured.contains(n) + }) + } Self::Frame(i) => i.parameters_changed(sample), } } @@ -456,4 +461,16 @@ mod tests { assert!(info.parameters_changed(&sample(&hvcc_pps[..6])), "a missing byte that is not zero is a change"); assert!(info.parameters_changed(&sample(&[0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x91])), "another PPS is a change"); } + + #[test] + fn repeated_parameter_sets_are_checked_once_per_distinct_nal() { + let pps = vec![0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x90]; + let info = StreamInfo::Nal(hevc_info(std::iter::repeat_n(pps.clone(), 256).collect())); + let mut sample = Vec::new(); + for _ in 0..256 { + sample.extend_from_slice(&(pps.len() as u32).to_be_bytes()); + sample.extend_from_slice(&pps); + } + assert!(!info.parameters_changed(&sample)); + } } From e1d559747729458fd533b5d22867c31d6f529d7a Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:12 +0200 Subject: [PATCH 036/110] security(av1): require unique complete tile coverage (#2917) --- crates/av1/src/decoder.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/av1/src/decoder.rs b/crates/av1/src/decoder.rs index eae16133b..c9f487d95 100644 --- a/crates/av1/src/decoder.rs +++ b/crates/av1/src/decoder.rs @@ -103,6 +103,8 @@ struct FrameJob { ref_info: [RefInfo; NUM_REF_FRAMES], /// Tile payloads (tile number, data). tiles: Vec<(usize, Vec)>, + /// Each tile may be supplied by exactly one tile group. + tile_covered: Vec, slot: Arc, /// The output picture, when the frame is shown. picture: Option>>>, @@ -531,12 +533,14 @@ impl Decoder { } let picture = fh.show_frame.then(Slot::new); let draft = self.draft && fh.show_frame && fh.refresh_frame_flags == 0; + let tile_count = fh.tile_info.cols.checked_mul(fh.tile_info.rows).ok_or(Error::Invalid("tile count overflows"))?; self.pending = Some(Box::new(FrameJob { seq, fh, refs: self.ref_frames.clone(), ref_info: self.refs.clone(), tiles: Vec::new(), + tile_covered: vec![false; tile_count], slot: Slot::new(), picture, pts: self.pts, @@ -579,13 +583,20 @@ impl Decoder { v + 1 }; let end = pos.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?; + if job.tile_covered.get(tile_num).copied() != Some(false) { + return Err(Error::Invalid("tile is covered by multiple tile groups")); + } job.tiles.push((tile_num, data[pos..end].to_vec())); + job.tile_covered[tile_num] = true; pos = end; } if tg_end != num_tiles - 1 { self.pending = Some(job); return Ok(None); } + if job.tile_covered.iter().any(|&covered| !covered) { + return Err(Error::Invalid("frame is missing tile coverage")); + } self.seen_frame_header = false; Ok(self.submit(job)) } From 9a0096a97940e9f2b3e53f23b33bbee28399ce66 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:17 +0200 Subject: [PATCH 037/110] security(automation): isolate bridge screenshots (#2919) --- Cargo.lock | 1 + crates/automation/Cargo.toml | 1 + crates/automation/src/server.rs | 10 ++++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae7b24f2c..30676cf2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1758,6 +1758,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "tokio", ] diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 3a2e4b965..9bd3e8766 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -20,3 +20,4 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "syn base64 = "0.22" schemars = "1" image = { version = "0.25", default-features = false, features = ["png"] } +tempfile = "3" diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 3a9cb8e1c..b2e313504 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -564,7 +564,14 @@ impl FilmcraftMcp { impl FilmcraftMcp { async fn screenshot(&self, b: &BridgeClient, panel: Option, max: u32) -> Result { - let path = std::env::temp_dir().join(format!("filmcraft-mcp-{}.png", std::process::id())); + // The desktop bridge needs a pathname, so use a freshly-created, + // owner-only directory rather than exposing image bytes at a + // predictable shared-temp filename. + let dir = match tempfile::tempdir() { + Ok(dir) => dir, + Err(e) => return Ok(fail(format!("screenshot: {e}"))), + }; + let path = dir.path().join("screenshot.png"); if let Err(e) = b.call("ui.screenshot", json!({"path": path.to_string_lossy(), "panel": panel})).await { return Ok(fail(e)); } @@ -572,7 +579,6 @@ impl FilmcraftMcp { Ok(b) => b, Err(e) => return Ok(fail(format!("screenshot: {e}"))), }; - let _ = tokio::fs::remove_file(&path).await; let bytes = match image::load_from_memory(&bytes) { Ok(img) => { let rgba = img.to_rgba8(); From b1effb9ab11ec367b0b363acf175e496f04fe52d Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:22 +0200 Subject: [PATCH 038/110] security(export): confine metadata-derived names (#2903) --- crates/engine/src/export_tests.rs | 10 ++++++++++ crates/engine/src/export_tools.rs | 5 +++-- crates/ui-egui/src/panels/export_mode.rs | 4 +++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 79ddcb810..2e6eb8a99 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -9,6 +9,16 @@ use serde_json::{Value, json}; use crate::Session; +#[test] +fn project_names_become_one_export_filename_component() { + for name in ["../../outside", "/absolute/path", r"C:\\outside", ".."] { + let safe = crate::export_tools::file_safe(name); + assert!(!safe.contains(['/', '\\']), "{name:?} became {safe:?}"); + assert_ne!(safe, "."); + assert_ne!(safe, ".."); + } +} + #[test] fn hostile_export_ranges_are_rejected_without_panicking() { let mut session = demo(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 98c7ad2fc..170bc142b 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -385,10 +385,11 @@ pub fn default_export_dir(s: &Session) -> PathBuf { crate::temp_dir() } -fn file_safe(n: &str) -> String { +/// Convert project-derived display text into exactly one safe output filename component. +pub fn file_safe(n: &str) -> String { let s: String = n.chars().map(|c| if c.is_alphanumeric() || " -_().".contains(c) { c } else { '_' }).collect(); let s = s.trim().to_string(); - if s.is_empty() { "Export".into() } else { s } + if s.is_empty() || matches!(s.as_str(), "." | "..") { "Export".into() } else { s } } /// `path` with the settings' extension (added when missing; image sequences keep theirs). diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index b8082433c..f1cf5224d 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -1414,7 +1414,9 @@ fn quick_file(app: &FilmcraftApp, path: &str, seq_name: &str, ext: &str) -> Opti return None; } let file = match name { - None => format!("{seq_name}.{ext}"), + // Sequence names come from the project, not from this Quick Export interaction. Keep + // metadata in one filename component before joining it to the selected/default folder. + None => format!("{}.{}", filmcraft_engine::export_tools::file_safe(seq_name), ext), Some(n) if cur.eq_ignore_ascii_case(ext) => n.to_string(), Some(n) if !cur.is_empty() && app.session.export_presets.all().iter().any(|x| x.settings.extension().eq_ignore_ascii_case(cur)) => { std::path::Path::new(n).with_extension(ext).to_string_lossy().to_string() From 10c2e0255c22408ebc039cde7b20fd9f976f327e Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:27 +0200 Subject: [PATCH 039/110] security(bitstream): cache RBSP stop-bit lookup (#2880) --- crates/bitstream/src/lib.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/bitstream/src/lib.rs b/crates/bitstream/src/lib.rs index 1bdb51265..25239e0c4 100644 --- a/crates/bitstream/src/lib.rs +++ b/crates/bitstream/src/lib.rs @@ -7,7 +7,7 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -use std::fmt; +use std::{cell::Cell, fmt}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BitError { @@ -35,11 +35,13 @@ pub struct BitReader<'a> { data: &'a [u8], /// Position in bits. pos: usize, + /// Cached position of the RBSP stop bit, computed only by [`Self::more_rbsp_data`]. + rbsp_stop: Cell>>, } impl<'a> BitReader<'a> { pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0 } + Self { data, pos: 0, rbsp_stop: Cell::new(None) } } pub fn data(&self) -> &'a [u8] { self.data @@ -157,14 +159,13 @@ impl<'a> BitReader<'a> { if self.pos >= total { return false; } - // find last set bit (the rbsp_stop_one_bit) - let mut last = None; - for (i, &b) in self.data.iter().enumerate().rev() { - if b != 0 { - last = Some(i * 8 + 7 - b.trailing_zeros() as usize); - break; - } - } + let last = self.rbsp_stop.get().unwrap_or_else(|| { + // The trailing stop bit is immutable for this reader. Caching it avoids searching a + // long zero suffix after every macroblock. + let stop = self.data.iter().rposition(|&b| b != 0).map(|i| i * 8 + 7 - self.data[i].trailing_zeros() as usize); + self.rbsp_stop.set(Some(stop)); + stop + }); match last { Some(stop) => self.pos < stop, None => false, @@ -443,7 +444,9 @@ mod tests { fn more_rbsp() { let d = [0b1100_0000]; let mut r = BitReader::new(&d); + assert_eq!(r.rbsp_stop.get(), None); assert!(r.more_rbsp_data()); + assert_eq!(r.rbsp_stop.get(), Some(Some(1))); r.read_bit().unwrap(); assert!(!r.more_rbsp_data()); } From b28db6c4db6d411c787fc35f5c74e7e7ed7cca5b Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:32 +0200 Subject: [PATCH 040/110] fix(bitstream): reject invalid length-prefixed NAL widths (#2953) * fix(bitstream): validate NAL prefix size * bitstream: accept every NAL length width the avcC/hvcC field can declare (1 to 4) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/bitstream/src/lib.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/bitstream/src/lib.rs b/crates/bitstream/src/lib.rs index 25239e0c4..5d2c8b64b 100644 --- a/crates/bitstream/src/lib.rs +++ b/crates/bitstream/src/lib.rs @@ -15,6 +15,8 @@ pub enum BitError { Eof, /// An Exp-Golomb code longer than 32 leading zeros. InvalidExpGolomb, + /// An AVCC/HVCC NAL length prefix is not one of the supported widths. + InvalidNalLengthSize, } impl fmt::Display for BitError { @@ -22,6 +24,7 @@ impl fmt::Display for BitError { match self { BitError::Eof => f.write_str("unexpected end of bitstream"), BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"), + BitError::InvalidNalLengthSize => f.write_str("NAL length prefix must be 1 to 4 bytes"), } } } @@ -315,8 +318,11 @@ pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> { out } -/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units. +/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1 to 4 bytes) into NAL units. pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result> { + if !matches!(length_size, 1..=4) { + return Err(BitError::InvalidNalLengthSize); + } let mut out = Vec::new(); let mut i = 0; while i < data.len() { @@ -440,6 +446,15 @@ mod tests { assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]); } + #[test] + fn length_prefixed_nals_rejects_invalid_prefix_sizes() { + for size in [0, 5, usize::MAX] { + assert!(length_prefixed_nals(&[0], size).is_err(), "size {size}"); + } + // `(lengthSizeMinusOne & 3) + 1` can be 3; such samples still split + assert_eq!(length_prefixed_nals(&[0, 0, 1, 0x65], 3).unwrap(), vec![&[0x65][..]]); + } + #[test] fn more_rbsp() { let d = [0b1100_0000]; From 34848b39649cae3e407f7d73373a64c050fe026a Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:36 +0200 Subject: [PATCH 041/110] security(vp8): bound frame allocation (#2929) * security(vp8): bound frame allocation * vp8: raise the frame-size cap to 2^26 luma samples so 8K frames still decode Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/vp8/src/decoder.rs | 11 +++++++++-- crates/vp8/tests/robustness.rs | 10 +++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/vp8/src/decoder.rs b/crates/vp8/src/decoder.rs index 0c030c740..50d8c517d 100644 --- a/crates/vp8/src/decoder.rs +++ b/crates/vp8/src/decoder.rs @@ -15,6 +15,9 @@ use crate::transform; /// Largest frame dimension in macroblocks (14-bit sizes). const MAX_MBS: usize = 16384 / 16 + 1; +/// Maximum padded luma samples retained by one VP8 reference picture (about 8K UHD). +/// 2^26 luma samples: 8192x8192, above every VP8 frame a camera or encoder writes (8192x4320 is 35M). +const MAX_LUMA_SAMPLES: usize = 1 << 26; /// A reconstructed frame: planes sized to whole macroblocks. #[derive(Clone)] @@ -162,14 +165,18 @@ impl Decoder { /// (`show_frame`), `None` for a hidden frame (an altref). pub fn decode(&mut self, data: &[u8], pts: i64) -> Result> { let tag = FrameTag::parse(data)?; + ensure!(tag.first_part_size > 0, "empty first partition"); if tag.key_frame { ensure!(tag.width > 0 && tag.height > 0, "key frame of size {}x{}", tag.width, tag.height); let (w, h) = (usize::from(tag.width), usize::from(tag.height)); + let (mb_w, mb_h) = (w.div_ceil(16), h.div_ceil(16)); + let samples = mb_w.checked_mul(16).and_then(|w| w.checked_mul(mb_h)).and_then(|h| h.checked_mul(16)); + ensure!(samples.is_some_and(|n| n <= MAX_LUMA_SAMPLES), "frame size {}x{} exceeds the decoder memory limit", tag.width, tag.height); if w != self.width || h != self.height { self.width = w; self.height = h; - self.mb_w = w.div_ceil(16); - self.mb_h = h.div_ceil(16); + self.mb_w = mb_w; + self.mb_h = mb_h; self.seg_map = vec![0; self.mb_w * self.mb_h]; } // key frame defaults (9.3, 9.4, 9.7, 13.4, 16.1, 17.2) diff --git a/crates/vp8/tests/robustness.rs b/crates/vp8/tests/robustness.rs index 7dfc4f897..a4027f15d 100644 --- a/crates/vp8/tests/robustness.rs +++ b/crates/vp8/tests/robustness.rs @@ -78,7 +78,15 @@ fn random_bytes_and_tiny_frames_do_not_panic() { key[3..6].copy_from_slice(&[0x9d, 0x01, 0x2a]); key[6..8].copy_from_slice(&1920u16.to_le_bytes()); key[8..10].copy_from_slice(&1080u16.to_le_bytes()); - let _ = dec.decode(&key, 0); + assert!(dec.decode(&key, 0).is_err(), "empty first partition must not reconstruct a frame"); + + // A header can declare nearly 16K square dimensions with just ten bytes. It must be rejected + // before the macroblock map and reference planes are allocated. + key[0] = 0x30; // one-byte first partition + key[10] = 0; + key[6..8].copy_from_slice(&16_383u16.to_le_bytes()); + key[8..10].copy_from_slice(&16_383u16.to_le_bytes()); + assert!(dec.decode(&key, 0).is_err(), "oversized key frame must be rejected"); } #[test] From 99ec34de30086d8576bae7745edd7fb0e1d108ba Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:40 +0200 Subject: [PATCH 042/110] security(vp9): bound frame allocation (#2930) * security(vp9): bound frame allocation * vp9: raise the frame-size cap to 2^26 luma samples so level 6.x 8K frames decode Co-Authored-By: Claude Opus 5.5 * vp9: keep the frame-size cap below the imports Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/vp9/src/decoder.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/vp9/src/decoder.rs b/crates/vp9/src/decoder.rs index af1f969c7..e220a7f62 100644 --- a/crates/vp9/src/decoder.rs +++ b/crates/vp9/src/decoder.rs @@ -15,6 +15,10 @@ use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex, MutexGuard, RwLock}; +/// Maximum luma samples accepted for one decoded reference frame: 8192x8192, above level 6.2's +/// 8192x4352 (35 651 584 samples). +const MAX_LUMA_SAMPLES: u64 = 1 << 26; + /// Counters describing what the decoder has seen (useful to check test coverage). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DecodeStats { @@ -257,6 +261,12 @@ impl Decoder { return Ok(()); } ensure!(h.width <= 16384 && h.height <= 16384, "frame size {}x{} too large", h.width, h.height); + ensure!( + u64::from(h.width).checked_mul(u64::from(h.height)).is_some_and(|n| n <= MAX_LUMA_SAMPLES), + "frame size {}x{} exceeds the decoder memory limit", + h.width, + h.height + ); let (mi_rows, mi_cols) = (h.mi_rows as usize, h.mi_cols as usize); // compute_image_size semantics (7.2.6). let size = (h.width, h.height); @@ -279,6 +289,7 @@ impl Decoder { let off = h.uncompressed_size; let hs = h.header_size_in_bytes as usize; ensure!(off + hs <= data.len(), "compressed header exceeds frame data"); + ensure!(off + hs < data.len(), "frame has no tile data"); let mut fc = self.st.contexts[h.frame_context_idx as usize].clone(); parse_compressed(&data[off..off + hs], &mut h, &mut fc)?; self.count_frame(&h); From 920ad641a8f782b61f9fb6d8bed6f4f4ac5a3bad Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:45 +0200 Subject: [PATCH 043/110] security(prores): bound decoded plane storage (#2931) * security(prores): bound decoded plane storage * prores: raise the decoded-plane cap to 1 GiB so 8K 4444 with alpha decodes Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/prores/src/decode.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/prores/src/decode.rs b/crates/prores/src/decode.rs index d25c49352..60318d848 100644 --- a/crates/prores/src/decode.rs +++ b/crates/prores/src/decode.rs @@ -6,6 +6,10 @@ use crate::header::{FrameHeader, PictureHeader, row_slice_widths}; use crate::tables::*; use crate::{AlphaType, ChromaFormat, Error, Frame, Interlace, Result}; +/// Maximum aggregate decoded plane storage for one ProRes frame (256 MiB). +// 1 GiB: 8K ProRes 4444 with alpha is 283 MB of 16-bit planes +const MAX_FRAME_BYTES: usize = 1 << 30; + /// Decoder options. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DecodeOptions { @@ -120,6 +124,15 @@ pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> let width = hdr.width as usize; let height = hdr.height as usize; let cwidth = hdr.chroma.chroma_width(hdr.width as u32) as usize; + let luma = width.checked_mul(height).ok_or(Error::Invalid("frame dimensions overflow"))?; + let chroma = cwidth.checked_mul(height).ok_or(Error::Invalid("chroma dimensions overflow"))?; + let samples = luma + .checked_add(chroma.checked_mul(2).ok_or(Error::Invalid("chroma plane count overflows"))?) + .and_then(|n| if hdr.alpha == AlphaType::None { Some(n) } else { n.checked_add(luma) }) + .ok_or(Error::Invalid("frame plane count overflows"))?; + if !samples.checked_mul(std::mem::size_of::()).is_some_and(|n| n <= MAX_FRAME_BYTES) { + return Err(Error::Invalid("frame dimensions exceed the decoder memory limit")); + } let interlaced = hdr.interlace != Interlace::Progressive; let mb_width = width.div_ceil(16); let mb_height = if interlaced { height.div_ceil(32) } else { height.div_ceil(16) }; From 6f87c0e381bb7e3a6841d294351e26b06bfcd998 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Fri, 23 Oct 2026 16:54:49 +0200 Subject: [PATCH 044/110] security(exr): validate chunks before frame allocation (#2913) * security(exr): validate chunks before frame allocation * exr: raise the decoded-buffer cap to 2 GiB so 8K float plates open Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/media/src/still/exr.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/media/src/still/exr.rs b/crates/media/src/still/exr.rs index d478e3a04..bd0cdef74 100644 --- a/crates/media/src/still/exr.rs +++ b/crates/media/src/still/exr.rs @@ -27,6 +27,10 @@ const MAGIC: [u8; 4] = [0x76, 0x2f, 0x31, 0x01]; /// Largest picture we decode (each pixel takes 16 bytes as RGBA floats). const MAX_PIXELS: u64 = 1 << 26; +/// Maximum memory retained while decoding the data window and, when different, +/// the display window. Both buffers coexist during placement. +/// 2 GiB: an 8K RGBA f32 plate is 566 MB and the data and display windows may both be held. +const MAX_DECODED_BYTES: usize = 2 << 30; /// Most attributes / channels we read from a header. const MAX_ATTRIBUTES: usize = 4096; const MAX_CHANNELS: usize = 1024; @@ -418,6 +422,34 @@ pub fn decode(name: &str, b: &[u8]) -> Result { } let table = h.end; let px_len = dw.checked_mul(dh).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("size".into()))?; + let frame_len = fw.checked_mul(fh).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("display window size".into()))?; + let decoded_bytes = px_len + .checked_mul(std::mem::size_of::()) + .and_then(|n| if h.data == h.display { Some(n) } else { frame_len.checked_mul(std::mem::size_of::()).and_then(|m| n.checked_add(m)) }) + .ok_or_else(|| bad("decoded size".into()))?; + if decoded_bytes > MAX_DECODED_BYTES { + return Err(MediaError::Unsupported(format!("{name}: EXR decoded buffers exceed the {MAX_DECODED_BYTES}-byte limit"))); + } + let chunks = dh.div_ceil(lpc); + let table_end = table.checked_add(chunks.checked_mul(8).ok_or_else(|| bad("offset table size".into()))?).ok_or_else(|| bad("offset table size".into()))?; + if table_end > b.len() { + return Err(bad("offset table is truncated".into())); + } + for i in 0..chunks { + let at = table + i * 8; + let off = le32(b, at).zip(le32(b, at + 4)).map(|(lo, hi)| u64::from(lo) | u64::from(hi) << 32); + let off = off.and_then(|o| usize::try_from(o).ok()).ok_or_else(|| bad(format!("offset of chunk {i}")))?; + let size = le32(b, off.checked_add(4).ok_or_else(|| bad(format!("chunk {i}")))?).ok_or_else(|| bad(format!("chunk {i}")))? as usize; + let end = off.checked_add(8).and_then(|n| n.checked_add(size)).ok_or_else(|| bad(format!("chunk {i}")))?; + if end > b.len() { + return Err(bad(format!("chunk {i} is truncated"))); + } + let y = le32(b, off).map(|y| y as i32); + let want = i64::from(h.data.y0) + (i * lpc) as i64; + if y.map(i64::from) != Some(want) { + return Err(bad(format!("chunk {i} is not at line {want}"))); + } + } let mut px = vec![0f32; px_len]; if !alpha { px.par_chunks_mut(4).for_each(|p| { From c5c16939896277ab332280a6d3f1d7283d0d0c4e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:18:37 -0400 Subject: [PATCH 045/110] Use chrono instead of hand-rolled UTC conversion (#2662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days / days_from_civil copies (app log timestamp, Media Browser date column, auto-save file-name stamp, MXF header timestamp, golden-image attribution date) with chrono as a workspace dependency. The existing chrono entry on apps/filmcraft (clock feature) was lifted to the workspace. Output formats are unchanged and pinned by tests. AGENTS.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Never panic on a clock outside chrono's range; keep 5-digit years unprefixed chrono's From unwraps internally, so a clock past year 262143 panicked. logging::timestamp and golden::today now read the duration since the epoch and go through the fallible DateTime::from_timestamp, reading an out-of-range or pre-epoch clock as the epoch as the old saturating code did. timestamp, today and media_browser::format_date write the year with {:04} rather than %Y, which prefixes a '+' past year 9999. Tests cover the far-future and year-10000 cases for timestamp and format_date. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * time: preserve out-of-range years instead of the epoch (FredApps review) `DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: inputs the old hand-rolled civil-from-days converted (e.g. `10_000_000_000_000` s → 318857-05-20 17:46:40) collapsed to `1970-01-01 00:00:00`. Restore the old conversion domain with a cycle-based helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the in-range remainder with chrono, and carry the year back as i128 (saturating to the output type). Only the year leaves chrono's range; month, day and time of day are always exact. Sites (all UTC): - apps/filmcraft logging.rs `timestamp` (SystemTime, u64 secs): pre-1970 still clamps to the epoch; a far-future clock keeps its year. u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z", 10e12 s → "318857-05-20T17:46:40.000Z" (was "1970-01-01..."). SystemTime cases built with a checked add (Windows-safe). - crates/engine media_browser.rs `format_date` (u64): 10e12 s → "318857-05-20 17:46", u64::MAX → "584554051223-11-09 07:00" (was "1970-01-01 00:00"). - crates/format autosave.rs `civil` (i64 + i32 offset): keeps years, e.g. i64::MAX → 292277026596-12-04 15:30:07, i64::MIN → -292277022657-01-27. The separate `unix + offset_secs` overflow guard (checked_add) is kept: only an overflowing shift (old code wrapped it to garbage) reads as the epoch. - crates/mxf from_unix (i64): keeps month/day/time, clamps only `year` to the u16 field as before. 10e12 s → year 65535, May 20 17:46:40; -10e12 s → year 0, Aug 13 06:13:20. - crates/testkit golden.rs `today` (u64): same helper; pinned via `utc_parts`. chrono stays the only calendar logic (NaiveDate::from_num_days_from_ce_opt on the in-cycle remainder). No new chrono features; still default-features = false, std. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- AGENTS.md | 2 + CLAUDE.md | 1 + Cargo.lock | 4 ++ Cargo.toml | 2 + apps/filmcraft/Cargo.toml | 2 +- apps/filmcraft/src/logging.rs | 55 +++++++++++++++++--------- crates/engine/Cargo.toml | 1 + crates/engine/src/media_browser.rs | 61 +++++++++++++++++++++-------- crates/format/Cargo.toml | 1 + crates/format/src/autosave.rs | 62 ++++++++++++++++++++++-------- crates/mxf/Cargo.toml | 1 + crates/mxf/src/write/mod.rs | 48 ++++++++++++++--------- crates/mxf/src/write/tests.rs | 16 ++++++++ crates/testkit/Cargo.toml | 1 + crates/testkit/src/golden.rs | 54 ++++++++++++++++++++------ 15 files changed, 231 insertions(+), 80 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0683cb87a..3f863866f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,8 @@ See [§0](#0-never-crash). It outranks every other engineering rule. See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). + Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in diff --git a/CLAUDE.md b/CLAUDE.md index 2ecdacc20..d28a9b06b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target - **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback. - **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`. - **Exact time:** all time is `filmcraft_time::Tick` (254 016 000 000/s). Never use f64 seconds for edit math. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. - **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde so the control channel can read/set it. - **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm` (checks L0–L4 crates). `cargo xtask ci` runs them all. diff --git a/Cargo.lock b/Cargo.lock index 30676cf2b..b5c05cd98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1909,6 +1909,7 @@ dependencies = [ name = "filmcraft-engine" version = "0.12.0" dependencies = [ + "chrono", "craft-docpath", "filmcraft-audio-dsp", "filmcraft-captions", @@ -1984,6 +1985,7 @@ dependencies = [ name = "filmcraft-format" version = "0.12.0" dependencies = [ + "chrono", "filmcraft-media", "filmcraft-project", "filmcraft-time", @@ -2160,6 +2162,7 @@ dependencies = [ name = "filmcraft-mxf" version = "0.12.0" dependencies = [ + "chrono", "filmcraft-testkit", ] @@ -2278,6 +2281,7 @@ dependencies = [ name = "filmcraft-testkit" version = "0.12.0" dependencies = [ + "chrono", "png", ] diff --git a/Cargo.toml b/Cargo.toml index 00aabadd3..341fb7ab3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" proptest = "1" roxmltree = "0.21" diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 6962a2330..e0730dc37 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -36,7 +36,7 @@ rfd = { workspace = true } cpal = "0.16" # Files and images on the system clipboard for Paste (#611); egui already uses it for text. arboard = "3" -chrono = { version = "0.4", default-features = false, features = ["clock"] } +chrono = { workspace = true, features = ["clock"] } sys-locale = "0.3" # Help menu / About / Discord links in the system browser (MIT; as PhotoCraft). open = "5" diff --git a/apps/filmcraft/src/logging.rs b/apps/filmcraft/src/logging.rs index 3d7c713fb..006b7f884 100644 --- a/apps/filmcraft/src/logging.rs +++ b/apps/filmcraft/src/logging.rs @@ -97,29 +97,35 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock +/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved) +/// instead of panicking. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd -/// clock can't overflow). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years +/// (146_097 days), so month, day and time of day come from chrono and only the year is carried, +/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is +/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -390,6 +396,17 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch (reviewer FredApps). Windows' SystemTime can't hold such an + // instant, so it is built with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 088163f10..d39450794 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -42,6 +42,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } craft-docpath = { workspace = true } web-time = "1" diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index d1e65f815..f2ea3013f 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -636,22 +636,33 @@ fn format_size(b: u64) -> String { } } -/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds. +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days` +/// (≤ `u64::MAX / 86_400`) always fits an `i64`. +fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds. Years far outside chrono's own range are preserved +/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn format_date(secs: u64) -> String { - let days = (secs / 86_400) as i64; - let rem = secs % 86_400; - // civil-from-days (Howard Hinnant's algorithm) - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem / 60) % 60) + let (y, t) = utc_parts(secs); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%d %H:%M")) } fn probe_cmd(s: &mut Session, p: &Value) -> Result { @@ -773,3 +784,23 @@ pub fn std_home_dir() -> Option { let vars = if cfg!(windows) { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] }; vars.iter().find_map(|v| std::env::var(v).ok().filter(|h| !h.is_empty())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_date_is_utc_minutes() { + // Pinned against the previous hand-rolled civil-from-days output. + assert_eq!(format_date(0), "1970-01-01 00:00"); + assert_eq!(format_date(951_782_400), "2000-02-29 00:00"); + assert_eq!(format_date(1_709_164_800), "2024-02-29 00:00"); + assert_eq!(format_date(1_790_775_907), "2026-09-30 13:45"); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(format_date(253_402_300_800), "10000-01-01 00:00"); + // Values far beyond chrono's own range are preserved (the old hand-rolled civil-from-days + // kept them); they must not collapse to the epoch (reviewer FredApps). + assert_eq!(format_date(10_000_000_000_000), "318857-05-20 17:46"); + assert_eq!(format_date(u64::MAX), "584554051223-11-09 07:00"); + } +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 3fcb96254..87c6be25b 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -16,6 +16,7 @@ log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3.27" diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index e026aed10..54543749b 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -25,23 +25,37 @@ pub fn file_stem(name: &str) -> String { if s.is_empty() { "Untitled".into() } else { s } } +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact instead of collapsing out-of-range years to the epoch. +fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + /// Civil date-time (proleptic Gregorian) of a unix timestamp shifted by `offset_secs`. +/// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept them); +/// only an overflowing `unix + offset_secs` shift (which the old code wrapped to garbage) reads as +/// the epoch. pub fn civil(unix: i64, offset_secs: i32) -> (i64, u32, u32, u32, u32, u32) { - let t = unix + offset_secs as i64; - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - // Howard Hinnant's days_from_civil inverse. - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d, (secs / 3600) as u32, (secs % 3600 / 60) as u32, (secs % 60) as u32) + use chrono::{Datelike, Timelike}; + let Some(s) = unix.checked_add(offset_secs as i64) else { + return (1970, 1, 1, 0, 0, 0); + }; + let (y, t) = utc_parts(s); + (y, t.month(), t.day(), t.hour(), t.minute(), t.second()) } /// `YYYY-MM-DD_HH-MM-SS` (the auto-save file-name stamp). @@ -137,6 +151,24 @@ mod tests { assert_eq!(timestamp(-1, 0), "1969-12-31_23-59-59"); } + #[test] + fn out_of_range_years_are_preserved_not_collapsed_to_the_epoch() { + // The old hand-rolled conversion kept years far outside chrono's own range; the refactor + // must too (reviewer FredApps). `10_000_000_000_000` s → 318857, not 1970. + assert_eq!(timestamp(10_000_000_000_000, 0), "318857-05-20_17-46-40"); + assert_eq!(display_time(10_000_000_000_000, 0), "318857-05-20 17:46:40"); + // chrono's own upper edge (year 10000) and a negative value far before it. + assert_eq!(timestamp(253_402_300_800, 0), "10000-01-01_00-00-00"); + assert_eq!(display_time(-10_000_000_000_000, 0), "-314918-08-13 06:13:20"); + // The input extremes the old code accepted (offset 0, so no shift overflow). + assert_eq!(display_time(i64::MAX, 0), "292277026596-12-04 15:30:07"); + assert_eq!(display_time(i64::MIN, 0), "-292277022657-01-27 08:29:52"); + // Only an overflowing `unix + offset_secs` shift (old code wrapped it to garbage) reads as + // the epoch; the separate overflow guard is kept. + assert_eq!(civil(i64::MAX, 3600), (1970, 1, 1, 0, 0, 0)); + assert_eq!(civil(i64::MIN, -3600), (1970, 1, 1, 0, 0, 0)); + } + #[test] fn names_and_stems() { assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00-0.fcproj"); diff --git a/crates/mxf/Cargo.toml b/crates/mxf/Cargo.toml index 34b62227f..78609cf63 100644 --- a/crates/mxf/Cargo.toml +++ b/crates/mxf/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +chrono = { workspace = true } [dev-dependencies] filmcraft-testkit = { workspace = true } diff --git a/crates/mxf/src/write/mod.rs b/crates/mxf/src/write/mod.rs index d18fdfb27..6852e1cd1 100644 --- a/crates/mxf/src/write/mod.rs +++ b/crates/mxf/src/write/mod.rs @@ -243,6 +243,26 @@ impl StartTimecode { } } +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact instead of collapsing out-of-range years to the epoch. +fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + /// An MXF timestamp (UTC). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Timestamp { @@ -256,27 +276,19 @@ pub struct Timestamp { } impl Timestamp { - /// From seconds since the Unix epoch (proleptic Gregorian, UTC). + /// From seconds since the Unix epoch (proleptic Gregorian, UTC). Years far outside chrono's own + /// range are preserved (the old hand-rolled conversion kept them) with month, day and time + /// intact; only the `year` is clamped to the MXF `u16` field, exactly as the old code did. pub fn from_unix(secs: i64) -> Timestamp { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); - // civil-from-days (H. Hinnant) - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; + use chrono::{Datelike, Timelike}; + let (y, t) = utc_parts(secs); Timestamp { year: y.clamp(0, 65535) as u16, - month: m as u8, - day: d as u8, - hour: (rem / 3600) as u8, - minute: (rem / 60 % 60) as u8, - second: (rem % 60) as u8, + month: t.month() as u8, + day: t.day() as u8, + hour: t.hour() as u8, + minute: t.minute() as u8, + second: t.second() as u8, millisecond: 0, } } diff --git a/crates/mxf/src/write/tests.rs b/crates/mxf/src/write/tests.rs index ddf935211..8728e86c8 100644 --- a/crates/mxf/src/write/tests.rs +++ b/crates/mxf/src/write/tests.rs @@ -267,6 +267,22 @@ fn timestamps() { assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (2023, 11, 14, 22, 13, 20)); let t = Timestamp::from_unix(951_782_400); // 2000-02-29 assert_eq!((t.year, t.month, t.day), (2000, 2, 29)); + // Years far outside chrono's own range are preserved with month/day/time intact and only the + // year clamped to the MXF `u16`, exactly as the old hand-rolled conversion did (not collapsed + // to the epoch — reviewer FredApps). `10_000_000_000_000` s → 318857-05-20 17:46:40. + let t = Timestamp::from_unix(10_000_000_000_000); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 5, 20, 17, 46, 40)); + // Year 10000 (just past chrono's range) still fits the u16 field. + let t = Timestamp::from_unix(253_402_300_800); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (10000, 1, 1, 0, 0, 0)); + // A negative year clamps to 0 but keeps its month/day/time. + let t = Timestamp::from_unix(-10_000_000_000_000); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 8, 13, 6, 13, 20)); + // The `i64` extremes the old code accepted. + let t = Timestamp::from_unix(i64::MAX); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 12, 4, 15, 30, 7)); + let t = Timestamp::from_unix(i64::MIN); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 1, 27, 8, 29, 52)); } #[test] diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index eca8a6c87..3e3f1f1ff 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -14,3 +14,4 @@ workspace = true # use it without creating dependency cycles. [dependencies] png = "0.18" +chrono = { workspace = true } diff --git a/crates/testkit/src/golden.rs b/crates/testkit/src/golden.rs index 922d4facb..6d52c14d5 100644 --- a/crates/testkit/src/golden.rs +++ b/crates/testkit/src/golden.rs @@ -195,18 +195,31 @@ pub fn diff_image(a: &Rgba8, b: &Rgba8) -> Rgba8 { /// Today's date (UTC) as YYYY-MM-DD, for attribution sidecars. fn today() -> String { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - // civil-from-days (Howard Hinnant) - let z = (secs / 86_400) as i64 + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + (m <= 2) as i64; - format!("{y:04}-{m:02}-{d:02}") + let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let (y, t) = utc_parts(since.as_secs()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%d")) +} + +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month and day come from chrono and only the year is carried, which keeps every input exact +/// instead of collapsing out-of-range years to the epoch. `secs` is `u64` (the clock is clamped at +/// the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } /// The attribution sidecar text for a generated golden image. @@ -283,6 +296,23 @@ pub fn assert_golden(golden: &Path, actual: &Rgba8, tol: Tolerance, title: &str, mod tests { use super::*; + fn ymd(secs: u64) -> String { + let (y, t) = utc_parts(secs); + format!("{y:04}-{}", t.format("%m-%d")) + } + + #[test] + fn utc_parts_preserves_out_of_range_years() { + // `today()` uses `utc_parts`. Years far outside chrono's own range are preserved (the old + // hand-rolled civil-from-days kept them) rather than collapsing to the epoch. + assert_eq!(ymd(0), "1970-01-01"); + assert_eq!(ymd(1_790_775_907), "2026-09-30"); + assert_eq!(ymd(951_782_400), "2000-02-29"); // 2000 is a leap year + assert_eq!(ymd(253_402_300_800), "10000-01-01"); // just past chrono's range + assert_eq!(ymd(10_000_000_000_000), "318857-05-20"); + assert_eq!(ymd(u64::MAX), "584554051223-11-09"); + } + fn ramp(w: u32, h: u32, alpha: u8) -> Rgba8 { let px = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8, (i / w * 255 / h) as u8, 77, alpha]).collect(); Rgba8::new(w, h, px) From 4d089668a84a770b6857afe70b40d361871d0e4c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 12:53:57 -0400 Subject: [PATCH 046/110] packaging: use [[ for bash conditional tests (SonarCloud S7688) (#3003) SonarCloud flags every bare [ conditional in bash scripts under packaging/ as a reliability smell (rule S7688). Convert the flagged [ ... ] tests to [[ ... ]]: safer word-splitting and no glob surprises, same semantics for these string and file tests. --- packaging/env.sh | 12 ++++++------ packaging/freebsd/package.sh | 14 +++++++------- packaging/linux/flatpak-bundle.sh | 8 ++++---- packaging/linux/install.sh | 12 ++++++------ packaging/linux/package.sh | 16 ++++++++-------- packaging/macos/import-cert.sh | 6 +++--- packaging/macos/package.sh | 22 +++++++++++----------- packaging/web/package.sh | 6 +++--- 8 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packaging/env.sh b/packaging/env.sh index d227a71ab..702318cde 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -22,7 +22,7 @@ workspace_version() { } VERSION="${FILMCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +32,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${FILMCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${FILMCRAFT_BUILD_SHA:-}" ]]; then FILMCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export FILMCRAFT_BUILD_SHA @@ -41,7 +41,7 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi } # Copy licence and readme files that exist into a package directory, plus the licences of the @@ -49,7 +49,7 @@ warn() { copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" } @@ -58,9 +58,9 @@ copy_docs() { # Does nothing without CRAFT_FONTS_DIR. copy_font_licences() { local dest="$1" lic family - if [ -z "${CRAFT_FONTS_DIR:-}" ]; then return 0; fi + if [[ -z "${CRAFT_FONTS_DIR:-}" ]]; then return 0; fi for lic in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do - [ -f "$lic" ] || continue + [[ -f "$lic" ]] || continue family="$(basename "$(dirname "$lic")")" cp "$lic" "$dest/OFL-$family.txt" done diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 36835b5aa..ab24da426 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -20,7 +20,7 @@ APP_ID=ai.storyteller.filmcraft SKIP_BUILD=0 DRY_RUN=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;; @@ -36,7 +36,7 @@ case "$MACHINE" in arm64 | aarch64) ARCH=aarch64 ;; *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; esac -if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then +if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 fi @@ -46,7 +46,7 @@ echo "==> FilmCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli) fi WORK="$CARGO_TARGET_DIR/freebsd-package" @@ -56,7 +56,7 @@ mkdir -p "$WORK" BIN="$CARGO_TARGET_DIR/release" OUT_DIR="$DIST" -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. BIN="$WORK/stub-bin" OUT_DIR="$WORK" @@ -81,13 +81,13 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$FILMCRAFT_BUILD_DATE/g" \ cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/" copy_docs "$STAGE/share/doc/filmcraft" for f in NOTICE ATTRIBUTION.md; do - if [ -f "$ROOT/$f" ]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/filmcraft/"; fi + if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/filmcraft/"; fi done for f in bin/filmcraft bin/filmcraft-cli "share/applications/$APP_ID.desktop" \ "share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \ share/doc/filmcraft/LICENSE-MIT share/doc/filmcraft/LICENSE-APACHE; do - if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi + if [[ ! -e "$STAGE/$f" ]]; then echo "error: $f is missing from the package" >&2; exit 1; fi done # ---- .tar.gz ------------------------------------------------------------------------------------ @@ -96,7 +96,7 @@ tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" "$STAGE/bin/filmcraft-cli" --version -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" fi diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 15d96c156..66406efb9 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -21,7 +21,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 TARBALL="" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; @@ -38,7 +38,7 @@ case "$ARCH" in esac BASENAME="filmcraft-$VERSION-linux-$ARCH" TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" -[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/filmcraft" ] || { echo "error: $TARBALL has no bin/filmcraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/filmcraft" ]] || { echo "error: $TARBALL has no bin/filmcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=filmcraft-cli "$APP_ID" --version diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh index 033fe9b7e..bd38730d5 100755 --- a/packaging/linux/install.sh +++ b/packaging/linux/install.sh @@ -9,10 +9,10 @@ usage() { } fail() { echo "error: $*" >&2; exit 1; } prefix="${HOME:?HOME is not set}/.local" -while [ "$#" -gt 0 ]; do +while [[ "$#" -gt 0 ]]; do case "$1" in --prefix) - [ "$#" -ge 2 ] || fail '--prefix needs an absolute path' + [[ "$#" -ge 2 ]] || fail '--prefix needs an absolute path' prefix="$2"; shift 2 ;; -h | --help) usage; exit 0 ;; *) usage >&2; fail "unknown argument: $1" ;; @@ -22,14 +22,14 @@ case "$prefix" in /*) ;; *) fail '--prefix must be an absolute path' ;; esac prefix="$(realpath -m -- "$prefix")" # Desktop Exec paths cannot contain '='; launchers also misinterpret '%' in executable names. [[ "$prefix" != *'='* && "$prefix" != *'%'* && ! "$prefix" =~ [[:cntrl:]] ]] || fail 'prefix cannot contain =, % or control characters' -[ "$prefix" != / ] || fail 'use /usr/local or a user directory, not /' +[[ "$prefix" != / ]] || fail 'use /usr/local or a user directory, not /' source_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" case "$prefix/" in "$source_dir/"*) fail 'choose an installation prefix outside the extracted tarball' ;; esac app_id=ai.storyteller.filmcraft for binary in filmcraft filmcraft-cli; do - [ -f "$source_dir/bin/$binary" ] || fail "missing bin/$binary; extract the complete tarball first" + [[ -f "$source_dir/bin/$binary" ]] || fail "missing bin/$binary; extract the complete tarball first" done -[ -f "$source_dir/share/applications/$app_id.desktop" ] || fail 'missing desktop entry; extract the complete tarball first' +[[ -f "$source_dir/share/applications/$app_id.desktop" ]] || fail 'missing desktop entry; extract the complete tarball first' mkdir -p -- "$prefix/bin" "$prefix/share" install -m755 -- "$source_dir/bin/filmcraft" "$prefix/bin/filmcraft" @@ -41,7 +41,7 @@ cp -R -- "$source_dir/share/." "$prefix/share/" # https://specifications.freedesktop.org/desktop-entry/latest/exec-variables.html exec_path="$(printf '%s' "$prefix/bin/filmcraft" | sed 's/[\\"`$]/\\&/g; s/\\/\\\\/g')" try_path="$(printf '%s' "$prefix/bin/filmcraft" | sed 's/\\/\\\\/g')" -while IFS= read -r line || [ -n "$line" ]; do +while IFS= read -r line || [[ -n "$line" ]]; do case "$line" in Exec=*) printf 'Exec="%s" %%F\n' "$exec_path" ;; TryExec=*) printf 'TryExec=%s\n' "$try_path" ;; diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index f92544f93..9194cb075 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -25,7 +25,7 @@ APP_ID=ai.storyteller.filmcraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -46,8 +46,8 @@ BASENAME="filmcraft-$VERSION-linux-$ARCH" echo "==> FilmCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli) @@ -113,9 +113,9 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then + if [[ -z "$TOOL" ]]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then + if [[ ! -x "$TOOL" ]]; then curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi @@ -138,16 +138,16 @@ if has appimage; then # package) appimagetool also writes the .zsync, into its working directory, hence the cd. (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/filmcraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/filmcraft-cli" --version fi echo "==> done" diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 2a11b5309..2acf4bf3d 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 85902b91b..c84028b59 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/filmcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/filmcraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> FilmCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli "${args[@]}") @@ -74,9 +74,9 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } @@ -88,8 +88,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -117,7 +117,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/FilmCraft-notarize.zip" notarize "$WORK/FilmCraft-notarize.zip" xcrun stapler staple "$APP" @@ -144,7 +144,7 @@ hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" xcrun stapler staple "$DMG" xcrun stapler validate "$DMG" @@ -162,7 +162,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/filmcraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/filmcraft-cli" --version echo "==> done" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index fa208cad6..0aa031325 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -11,10 +11,10 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then # CI (the shared release workflow installs only trunk): install the wasm-bindgen CLI at the # crate's exact version. Locally, `cargo xtask web` says which version to install. - if [ -n "${CI:-}" ]; then + if [[ -n "${CI:-}" ]]; then WASM_BINDGEN_VERSION="$(sed -n 's/^const WASM_BINDGEN: &str = "\(.*\)";$/\1/p' "$ROOT/xtask/src/main.rs")" if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -qF "$WASM_BINDGEN_VERSION"; }; then cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked @@ -24,7 +24,7 @@ if [ "${1:-}" != "--skip-build" ]; then fi SITE="$CARGO_TARGET_DIR/web/dist" -[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix. if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 From 20e159dd68c2c4dd5d0665e79aa32ed039b0a171 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 13:08:41 -0400 Subject: [PATCH 047/110] tools: quit the super-linear regex scans in the spec table extractors (SonarCloud S8786) (#3007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * packaging: use [[ for bash conditional tests (SonarCloud S7688) SonarCloud flags every bare [ conditional in bash scripts under packaging/ as a reliability smell (rule S7688). Convert the flagged [ ... ] tests to [[ ... ]]: safer word-splitting and no glob surprises, same semantics for these string and file tests. * tools: quit the super-linear regex scans in the spec table extractors (SonarCloud S8786) SonarCloud flags five regular expressions in the AV1/HEVC extract_tables.py tools as super-linear: a lazy (.*) under re.S that rescans the whole remaining spec text at every position, a \s*([^|]+?)\s* sandwich, and \s+(.*) tails that can absorb the same whitespace repeatedly. - the fence block reader now walks the ~~~~ c / tilde-run pairs with two linear searches instead of the lazy .*? findall; block-pair and 7+ tilde-run semantics are unchanged - the symbol-row capture restricts its first and last characters away from [|] so the lazy group's backtracking jumps are gone (identical for every non-blank cell; a cell of only spaces is no longer captured, which the table rows never contain) - the empty-comma cell splitter no longer lets \s* swallow the newline it anchors on; consecutive line breaks inside a cell no longer split (spec cells are single-line rows) - initValue / ScalingList value captures start on a non-space so \\s+ and .* no longer share a split point (captured text is unchanged for all real values) Verified old vs new run on synthetic spec markdown: byte-identical spé tables output and stderr. --- crates/av1/tools/extract_tables.py | 21 ++++++++++++++++++--- crates/hevc/tools/extract_tables.py | 4 ++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/av1/tools/extract_tables.py b/crates/av1/tools/extract_tables.py index e64a931f1..a037a3765 100644 --- a/crates/av1/tools/extract_tables.py +++ b/crates/av1/tools/extract_tables.py @@ -49,7 +49,7 @@ def ev(expr): # --- constants: symbol table (section 3) --- pending = [] for line in texts["03.symbols.md"].split("\n"): - m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^|]+?)\s*\|", line) + m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^\s|](?:[^|]*[^|\s])?)\s*\|", line) if m: pending.append((m.group(1), m.group(2))) # --- constants: value/name tables (section 6 semantics, section 8/9) --- @@ -91,7 +91,7 @@ for name, val in pending: def cells(text): """Split one initialiser cell. A missing comma between two operands on separate lines (Split_Tx_Size in the spec text: `TX_32X32` / `TX_4X8`) yields two cells.""" - parts = re.split(r"(?<=[A-Za-z0-9_])\s*\n\s*(?=[A-Za-z0-9_])", text.strip()) + parts = re.split(r"(?<=[A-Za-z0-9_])[^\S\n]*\n[^\S\n]*(?=[A-Za-z0-9_])", text.strip()) if len(parts) > 1: sys.stderr.write("note: missing comma in spec table between %s\n" % parts) return parts @@ -132,10 +132,25 @@ def shape(x): return [] +# `~~~~` opens a block on its own line (with an optional `c` marker) and the next run of +# tildes closes it. Walk the pairs with two linear searches instead of the lazy `(.*?)` +# with re.S, which rescans the whole remaining text at every position (super-linear). +FENCE_OPEN = re.compile(r"~~~~~?\s*c?\n") +FENCE_CLOSE = re.compile(r"~~~~~?") + tables = {} for f in files: t = texts[f] - for block in re.findall(r"~~~~~?\s*c?\n(.*?)~~~~~?", t, re.S): + pos = 0 + while True: + m = FENCE_OPEN.search(t, pos) + if not m: + break + c = FENCE_CLOSE.search(t, m.end()) + if not c: + break + block = t[m.end():c.start()] + pos = c.end() b = re.sub(r"//[^\n]*", "", block) b = re.sub(r"/\*.*?\*/", "", b, flags=re.S) for m in re.finditer(r"(?m)^\s*([A-Z][A-Za-z0-9_]*)\s*((?:\[[^\]=]*\]\s*)+)=\s*", b): diff --git a/crates/hevc/tools/extract_tables.py b/crates/hevc/tools/extract_tables.py index 6ee5759df..8b7d7d35e 100644 --- a/crates/hevc/tools/extract_tables.py +++ b/crates/hevc/tools/extract_tables.py @@ -44,7 +44,7 @@ def body_after(title_regex, stop_regex=r"^\s*(Table \d+-\d+ - |\d+\s+Rec\. ITU|R def init_values(table): vals = [] for l in body_after(r"Table %s - Values of initValue" % re.escape(table)): - m = re.match(r"^\s*initValue\s+(.*)$", l) + m = re.match(r"^\s*initValue\s+(\S.*)$", l) if m: vals += [int(v) for v in m.group(1).split()] return vals @@ -118,7 +118,7 @@ w("") # Table 7-6: default 8x8 scaling lists (intra matrixId 0..2, inter 3..5) intra, inter = [], [] for l in body_after(r"Table 7-6 - Specification of default values of ScalingList"): - m = re.match(r"^\s*ScalingList\[ 1\.\.3 \]\[ (0\.\.2|3\.\.5) \]\[ i \]\s+(.*)$", l) + m = re.match(r"^\s*ScalingList\[ 1\.\.3 \]\[ (0\.\.2|3\.\.5) \]\[ i \]\s+(\S.*)$", l) if m: (intra if m.group(1) == "0..2" else inter).extend(int(v) for v in m.group(2).split()) assert len(intra) == 64 and len(inter) == 64 From 0a881266c85d3e2219274afa918d7c1474a51a4e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:34:16 -0400 Subject: [PATCH 048/110] CI: xtask ci in the dev profile, one test binary per crate, nextest (#2860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tests: one integration-test binary per crate Each crate with several tests/*.rs files keeps them as modules of tests/it/, so cargo links one test executable per crate instead of one per file. Files that change process-wide state or time a workload stay binaries of their own. * Profiles: line tables for workspace crates, no debug info for dependencies * Windows ARM64: nightly and on release pushes instead of every push to main * xtask ci: clippy and tests in the dev profile The release profile's thin LTO and single codegen unit made linking the test binaries take most of the gate. The codec and audio crates stay at opt-level 3 in the dev profile, so their tests run at release speed. * xtask ci: nextest, doctests, no examples, one wasm check Tests run with cargo nextest when it is installed (cargo test otherwise) and build with --lib --bins --tests, so examples aren't linked; doctests run separately. The wasm check keeps going past the first failing crate. VP8 fixture generation locks across processes, since nextest runs each test in its own. * VP9 synthetic fixtures: write aside and rename A test in another process could read a fixture ffmpeg was still writing. * Fuzz tests: a third of each sweep by default, all of it with FILMCRAFT_FUZZ_ITERS Every seed, mutation kind and thread count still runs. Header bytes each get one overwrite value and two bit flips in rotation, so every value and bit index still occurs across the region. FILMCRAFT_FUZZ_ITERS=N runs the whole sweep plus N random rounds per seed, as before. * Platform fallback tests: one failure point per hybrid path The failure-anywhere test injected a failure at four points around every restart point; the hybrid's paths differ only in the first GOP, around the second and last restart points, at the end and never. The replay test needs the first two GOPs. * Export tests: shorter ranges with the same checks The bitrate test measures 0.5–1.5 s, where each preset reaches its own target (on 0–3 s the simple demo left two presets at the same rate). The every-preset test exports three frames per preset. * Audio tests: effects in parallel, a table-driven FFT for the Opus quality metric The extreme-settings test keeps both settings and the 40 s tail for every effect, one thread each. The Opus quality metric's FFT builds its twiddles once and allocates nothing per transform (same algorithm and summation order). * CI: dev-profile gate with nextest xtask ci no longer builds with --release, so the release-profile overrides go; nextest is installed for the test step. * CI: apt packages through the cached install-apt-packages action * Review fixes: fuzz tests with a panic hook in their own binaries, loud fixture lock The fuzz tests of av1, h264, hevc, mpeg2v, vp8 and vp9 count panics through a process-wide hook, so a panic in another test of the same process would fail them; they are binaries of their own again. The VP8 fixture lock fails the test instead of skipping it silently, and the full-sweep doc comments say how many truncation points the full run samples. * Fuzz tests: rotate the default subset per run (FILMCRAFT_FUZZ_SEED replays one) * platform tests: decode-switch tests in their own binaries, one NVENC lock --- .config/nextest.toml | 14 ++++ .github/workflows/ci.yml | 20 ++--- .github/workflows/windows-arm64.yml | 13 ++- Cargo.toml | 4 + apps/filmcraft-cli/tests/{ => it}/cli.rs | 0 apps/filmcraft-cli/tests/it/main.rs | 2 + .../filmcraft-cli/tests/{ => it}/workflows.rs | 0 apps/filmcraft-web/src/download_pacing.rs | 2 +- apps/filmcraft-web/src/recovery_policy.rs | 2 +- .../tests/{ => it}/download_pacing.rs | 6 +- apps/filmcraft-web/tests/it/main.rs | 2 + .../tests/{ => it}/recovery_policy.rs | 6 +- crates/aac/tests/{ => it}/common/mod.rs | 0 crates/aac/tests/{ => it}/decoder_oracle.rs | 2 +- crates/aac/tests/{ => it}/encoder_oracle.rs | 2 +- crates/aac/tests/it/main.rs | 4 + crates/aac/tests/{ => it}/robustness.rs | 2 +- crates/apv/README.md | 4 +- crates/apv/tests/{ => it}/fuzz.rs | 0 crates/apv/tests/{ => it}/fuzz_hostile.rs | 0 crates/apv/tests/it/main.rs | 3 + crates/apv/tests/{ => it}/oracle.rs | 0 crates/audio-dsp/src/effects/mod.rs | 54 +++++++------ crates/av1/README.md | 12 +-- crates/av1/tests/{ => it}/adhoc.rs | 4 +- crates/av1/tests/{ => it}/common/mod.rs | 0 crates/av1/tests/{ => it}/conformance.rs | 4 +- crates/av1/tests/{ => it}/hostile_dims.rs | 0 crates/av1/tests/it/main.rs | 7 ++ crates/av1/tests/{ => it}/oracle_inter.rs | 2 +- crates/av1/tests/{ => it}/oracle_intra.rs | 2 +- crates/av1/tests/{ => it}/perf.rs | 4 +- crates/captions/README.md | 2 +- crates/captions/tests/it/main.rs | 3 + crates/captions/tests/{ => it}/roundtrip.rs | 0 .../tests/{ => it}/ttml_body_timing.rs | 0 .../captions/tests/{ => it}/ttml_speakers.rs | 0 .../tests/{ => it}/aac_channels_oracle.rs | 2 +- crates/codecs/tests/{ => it}/adts_file.rs | 0 crates/codecs/tests/{ => it}/common/mod.rs | 0 .../codecs/tests/{ => it}/draft_decoders.rs | 2 +- crates/codecs/tests/{ => it}/eac3_oracle.rs | 2 +- .../tests/{ => it}/frame_color_oracle.rs | 2 +- crates/codecs/tests/{ => it}/h264_draft.rs | 2 +- crates/codecs/tests/it/main.rs | 17 ++++ .../tests/{ => it}/matroska_audio_oracle.rs | 2 +- crates/codecs/tests/{ => it}/media_oracle.rs | 2 +- .../codecs/tests/{ => it}/mkv_mp1_oracle.rs | 2 +- crates/codecs/tests/{ => it}/mp4_oracle.rs | 2 +- crates/codecs/tests/{ => it}/mp4_rtmd.rs | 0 crates/codecs/tests/{ => it}/mpeg_oracle.rs | 2 +- crates/codecs/tests/{ => it}/mxf_oracle.rs | 2 +- crates/codecs/tests/{ => it}/ogg_oracle.rs | 2 +- crates/codecs/tests/{ => it}/rf64_metadata.rs | 0 crates/codecs/tests/{ => it}/y4m_file.rs | 0 crates/codecs/tests/pool_bitexact.rs | 1 + crates/dng/README.md | 4 +- crates/dng/tests/it/main.rs | 2 + crates/dng/tests/{ => it}/oracle_libraw.rs | 0 crates/dng/tests/{ => it}/roundtrip.rs | 0 crates/dnx/README.md | 8 +- crates/dnx/tests/{ => it}/common/mod.rs | 0 crates/dnx/tests/{ => it}/fuzz_hostile.rs | 73 +++++++++++++++-- crates/dnx/tests/it/main.rs | 6 ++ crates/dnx/tests/{ => it}/oracle_decode.rs | 2 +- crates/dnx/tests/{ => it}/oracle_encode.rs | 2 +- crates/dnx/tests/{ => it}/perf.rs | 2 +- crates/dnx/tests/{ => it}/roundtrip.rs | 0 crates/engine/src/export_tests.rs | 25 +++--- crates/export/tests/it/main.rs | 2 + crates/export/tests/{ => it}/pool_trim.rs | 0 .../tests/{ => it}/renderer_reentrancy.rs | 2 +- crates/h264/README.md | 6 +- crates/h264/tests/fuzz_hostile.rs | 74 +++++++++++++++-- crates/h264/tests/{ => it}/common/mod.rs | 2 +- crates/h264/tests/{ => it}/conformance.rs | 2 +- crates/h264/tests/{ => it}/headers.rs | 2 +- crates/h264/tests/it/main.rs | 6 ++ crates/h264/tests/{ => it}/perf.rs | 4 +- crates/h264/tests/{ => it}/pps_validation.rs | 0 crates/h264/tests/{ => it}/robustness.rs | 2 +- crates/h264enc/examples/h264enc_synth.rs | 2 +- crates/h264enc/tests/{ => it}/common/mod.rs | 0 .../tests/{ => it}/crf_level_and_note.rs | 2 +- .../h264enc/tests/{ => it}/ffmpeg_oracle.rs | 2 +- crates/h264enc/tests/it/main.rs | 3 + crates/hevc/README.md | 6 +- crates/hevc/tests/fuzz_hostile.rs | 74 +++++++++++++++-- crates/hevc/tests/{ => it}/common/mod.rs | 2 +- crates/hevc/tests/{ => it}/conformance.rs | 2 +- crates/hevc/tests/it/main.rs | 4 + crates/hevc/tests/{ => it}/perf.rs | 4 +- crates/hevc/tests/{ => it}/robustness.rs | 2 +- crates/interchange/README.md | 6 +- crates/interchange/tests/{ => it}/aaf_omf.rs | 2 +- crates/interchange/tests/{ => it}/api.rs | 2 +- crates/interchange/tests/{ => it}/edl.rs | 2 +- crates/interchange/tests/{ => it}/fcp7.rs | 2 +- crates/interchange/tests/{ => it}/fcpxml.rs | 2 +- .../interchange/tests/{ => it}/file_urls.rs | 0 crates/interchange/tests/it/main.rs | 12 +++ .../tests/{ => it}/media_marker_export.rs | 2 +- .../tests/{ => it}/nested_sequence_markers.rs | 2 +- crates/interchange/tests/{ => it}/otio.rs | 2 +- .../tests/{ => it}/point_markers.rs | 2 +- .../interchange/tests/{ => it}/support/mod.rs | 0 .../tests/{ => it}/uncarried_clips.rs | 2 +- crates/isobmff/README.md | 8 +- crates/isobmff/tests/{ => it}/common/mod.rs | 0 crates/isobmff/tests/{ => it}/fuzz.rs | 2 +- crates/isobmff/tests/it/main.rs | 5 ++ crates/isobmff/tests/{ => it}/oracle_demux.rs | 2 +- crates/isobmff/tests/{ => it}/oracle_mux.rs | 2 +- crates/isobmff/tests/{ => it}/roundtrip.rs | 0 crates/media/tests/{ => it}/exr_oracle.rs | 0 crates/media/tests/{ => it}/hdr_oracle.rs | 0 crates/media/tests/it/main.rs | 3 + crates/media/tests/{ => it}/tiff_ycbcr.rs | 0 crates/mpeg2v/README.md | 6 +- crates/mpeg2v/src/synth_tests.rs | 2 +- crates/mpeg2v/src/tests.rs | 2 +- crates/mpeg2v/tests/fuzz_hostile.rs | 78 +++++++++++++++--- crates/mpeg2v/tests/{ => it}/common/mod.rs | 0 crates/mpeg2v/tests/{ => it}/hostile_dims.rs | 0 crates/mpeg2v/tests/it/main.rs | 4 + crates/mpeg2v/tests/{ => it}/oracle.rs | 2 +- crates/mpeg2v/tests/{ => it}/perf.rs | 6 +- crates/mpegts/README.md | 2 +- crates/mxf/README.md | 2 +- crates/ogg/README.md | 2 +- crates/opus/tests/{ => it}/common/mod.rs | 50 +++++++++--- crates/opus/tests/it/main.rs | 4 + crates/opus/tests/{ => it}/oracle.rs | 2 +- crates/opus/tests/{ => it}/robustness.rs | 0 crates/opus/tests/{ => it}/vectors.rs | 2 +- crates/platform/README.md | 20 ++--- crates/platform/tests/{ => it}/common/mod.rs | 4 + crates/platform/tests/{ => it}/fallback.rs | 43 +++++----- .../tests/{ => it}/hardware_encode.rs | 0 crates/platform/tests/{ => it}/hevc_probe.rs | 0 crates/platform/tests/it/main.rs | 13 +++ .../tests/{ => it}/media_foundation.rs | 2 +- crates/platform/tests/{ => it}/nvenc.rs | 0 .../platform/tests/{ => it}/nvenc_export.rs | 7 +- crates/platform/tests/{ => it}/nvenc_hevc.rs | 17 ++-- .../tests/{ => it}/nvenc_hevc_export.rs | 11 +-- .../tests/{ => it}/nvenc_hevc_main10.rs | 11 +-- .../tests/{ => it}/nvenc_hevc_probe.rs | 0 .../tests/{ => it}/nvenc_rgba_input.rs | 9 +-- .../platform/tests/{ => it}/videotoolbox.rs | 2 +- crates/platform/tests/media_stack.rs | 1 + crates/platform/tests/nvdec.rs | 4 +- crates/platform/tests/setting.rs | 1 + crates/platform/tests/vaapi.rs | 4 +- crates/platform/tests/vaapi_displays.rs | 1 + crates/prores/README.md | 8 +- crates/prores/tests/{ => it}/common/mod.rs | 0 crates/prores/tests/{ => it}/fuzz.rs | 2 +- crates/prores/tests/{ => it}/fuzz_hostile.rs | 0 crates/prores/tests/it/main.rs | 7 ++ crates/prores/tests/{ => it}/oracle_decode.rs | 2 +- crates/prores/tests/{ => it}/oracle_encode.rs | 2 +- crates/prores/tests/{ => it}/perf.rs | 4 +- crates/prores/tests/{ => it}/roundtrip.rs | 2 +- crates/render/tests/it/main.rs | 4 + crates/render/tests/{ => it}/median_radius.rs | 0 .../tests/{ => it}/noise_frame_cadence.rs | 0 .../tests/{ => it}/sharpen_preview_res.rs | 0 .../tests/{ => it}/underline_direction.rs | 0 crates/ui-egui/tests/{ => it}/audio51_ui.rs | 0 .../tests/{ => it}/audio_fx_editor_ui.rs | 0 .../tests/{ => it}/caption_properties_ui.rs | 0 .../tests/{ => it}/captions_font_ui.rs | 0 crates/ui-egui/tests/{ => it}/chrome_ui.rs | 0 .../ui-egui/tests/{ => it}/clip_audio_ui.rs | 0 .../ui-egui/tests/{ => it}/clip_dialogs_ui.rs | 0 .../tests/{ => it}/clipboard_paste_ui.rs | 0 crates/ui-egui/tests/{ => it}/color_ui.rs | 0 .../{ => it}/delete_caption_tracks_ui.rs | 0 crates/ui-egui/tests/{ => it}/deselect_ui.rs | 0 .../tests/{ => it}/dialog_buttons_ui.rs | 0 .../tests/{ => it}/edit_point_menu_ui.rs | 0 .../ui-egui/tests/{ => it}/escape_drag_ui.rs | 0 .../tests/{ => it}/essential_sound_ui.rs | 0 .../ui-egui/tests/{ => it}/export_menu_ui.rs | 0 crates/ui-egui/tests/{ => it}/export_ui.rs | 0 .../ui-egui/tests/{ => it}/frame_export_ui.rs | 0 .../tests/{ => it}/graphics_handles_ui.rs | 0 .../tests/{ => it}/graphics_notice_ui.rs | 0 .../tests/{ => it}/graphics_templates_ui.rs | 0 crates/ui-egui/tests/{ => it}/graphics_ui.rs | 0 .../tests/{ => it}/interchange_export_ui.rs | 0 .../tests/{ => it}/interface_zoom_ui.rs | 0 crates/ui-egui/tests/{ => it}/keyboard_ui.rs | 0 crates/ui-egui/tests/{ => it}/keyframes_ui.rs | 0 crates/ui-egui/tests/{ => it}/links_ui.rs | 0 crates/ui-egui/tests/it/main.rs | 80 +++++++++++++++++++ .../{ => it}/marked_playback_position.rs | 0 .../tests/{ => it}/mask_roundness_ui.rs | 0 crates/ui-egui/tests/{ => it}/masks_ui.rs | 0 crates/ui-egui/tests/{ => it}/media_ui.rs | 0 crates/ui-egui/tests/{ => it}/menus_ui.rs | 0 crates/ui-egui/tests/{ => it}/meters_ui.rs | 0 crates/ui-egui/tests/{ => it}/mixer_ui.rs | 0 .../tests/{ => it}/monitor_geometry.rs | 0 crates/ui-egui/tests/{ => it}/multicam_ui.rs | 0 .../tests/{ => it}/native_dialogs_ui.rs | 0 .../ui-egui/tests/{ => it}/nest_editing_ui.rs | 0 .../tests/{ => it}/nest_rendering_ui.rs | 0 crates/ui-egui/tests/{ => it}/nested_ui.rs | 0 crates/ui-egui/tests/{ => it}/panels_ui.rs | 0 crates/ui-egui/tests/{ => it}/par_ui.rs | 0 crates/ui-egui/tests/{ => it}/perf_ui.rs | 0 .../tests/{ => it}/playback_clock_ui.rs | 0 .../tests/{ => it}/project_panel_ui.rs | 0 .../tests/{ => it}/properties_scroll_ui.rs | 0 crates/ui-egui/tests/{ => it}/remix_ui.rs | 0 crates/ui-egui/tests/{ => it}/scripted.rs | 2 +- .../tests/{ => it}/sequence_tabs_ui.rs | 0 crates/ui-egui/tests/{ => it}/sequence_ui.rs | 0 crates/ui-egui/tests/{ => it}/settings_ui.rs | 0 .../tests/{ => it}/shared_docking_ui.rs | 0 .../ui-egui/tests/{ => it}/source_drag_ui.rs | 0 .../tests/{ => it}/source_playback_ui.rs | 0 crates/ui-egui/tests/{ => it}/timecode_ui.rs | 0 .../tests/{ => it}/timeline_drop_ui.rs | 0 .../tests/{ => it}/timeline_escape_ui.rs | 0 .../ui-egui/tests/{ => it}/timeline_gap_ui.rs | 0 .../tests/{ => it}/timeline_marquee_ui.rs | 0 .../tests/{ => it}/timeline_middle_pan_ui.rs | 0 .../tests/{ => it}/timeline_option_drag_ui.rs | 0 .../tests/{ => it}/timeline_scroll_ui.rs | 0 .../tests/{ => it}/timeline_tabs_ui.rs | 0 .../{ => it}/timeline_track_height_ui.rs | 0 .../{ => it}/timeline_transition_move_ui.rs | 0 .../tests/{ => it}/timeline_trim_ui.rs | 4 +- .../ui-egui/tests/{ => it}/track_color_ui.rs | 0 .../tests/{ => it}/track_select_drag_ui.rs | 0 .../ui-egui/tests/{ => it}/transcript_ui.rs | 0 .../tests/{ => it}/transform_handles_ui.rs | 0 .../tests/{ => it}/transition_drop_ui.rs | 0 .../ui-egui/tests/{ => it}/transitions_ui.rs | 0 crates/ui-egui/tests/{ => it}/tts_ui.rs | 0 .../tests/{ => it}/ui_control_input.rs | 0 .../ui-egui/tests/{ => it}/ui_dock_resize.rs | 0 .../tests/{ => it}/ui_keyboard_focus.rs | 0 crates/ui-egui/tests/{ => it}/unlink_ui.rs | 0 crates/ui-egui/tests/{ => it}/view_ui.rs | 0 crates/ui-egui/tests/{ => it}/voiceover_ui.rs | 0 .../ui-egui/tests/{ => it}/workspaces_ui.rs | 0 crates/vp8/README.md | 4 +- crates/vp8/tests/{ => it}/common/mod.rs | 10 ++- crates/vp8/tests/{ => it}/conformance.rs | 2 +- crates/vp8/tests/it/main.rs | 4 + crates/vp8/tests/{ => it}/robustness.rs | 2 +- crates/vp8/tests/{ => it}/vectors.rs | 2 +- crates/vp9/README.md | 8 +- crates/vp9/tests/fuzz_hostile.rs | 73 +++++++++++++++-- crates/vp9/tests/{ => it}/common/mod.rs | 0 crates/vp9/tests/{ => it}/conformance.rs | 2 +- crates/vp9/tests/it/main.rs | 5 ++ crates/vp9/tests/{ => it}/perf.rs | 4 +- crates/vp9/tests/{ => it}/robustness.rs | 2 +- crates/vp9/tests/{ => it}/synthetic.rs | 9 ++- docs/contributing.md | 4 +- docs/gaps.md | 2 +- docs/keyboard.md | 2 +- docs/monitors.md | 4 +- docs/performance.md | 10 +-- docs/testing.md | 70 +++++++++------- xtask/src/main.rs | 62 ++++++++------ 271 files changed, 952 insertions(+), 373 deletions(-) create mode 100644 .config/nextest.toml rename apps/filmcraft-cli/tests/{ => it}/cli.rs (100%) create mode 100644 apps/filmcraft-cli/tests/it/main.rs rename apps/filmcraft-cli/tests/{ => it}/workflows.rs (100%) rename apps/filmcraft-web/tests/{ => it}/download_pacing.rs (87%) create mode 100644 apps/filmcraft-web/tests/it/main.rs rename apps/filmcraft-web/tests/{ => it}/recovery_policy.rs (96%) rename crates/aac/tests/{ => it}/common/mod.rs (100%) rename crates/aac/tests/{ => it}/decoder_oracle.rs (99%) rename crates/aac/tests/{ => it}/encoder_oracle.rs (99%) create mode 100644 crates/aac/tests/it/main.rs rename crates/aac/tests/{ => it}/robustness.rs (99%) rename crates/apv/tests/{ => it}/fuzz.rs (100%) rename crates/apv/tests/{ => it}/fuzz_hostile.rs (100%) create mode 100644 crates/apv/tests/it/main.rs rename crates/apv/tests/{ => it}/oracle.rs (100%) rename crates/av1/tests/{ => it}/adhoc.rs (92%) rename crates/av1/tests/{ => it}/common/mod.rs (100%) rename crates/av1/tests/{ => it}/conformance.rs (94%) rename crates/av1/tests/{ => it}/hostile_dims.rs (100%) create mode 100644 crates/av1/tests/it/main.rs rename crates/av1/tests/{ => it}/oracle_inter.rs (99%) rename crates/av1/tests/{ => it}/oracle_intra.rs (99%) rename crates/av1/tests/{ => it}/perf.rs (97%) create mode 100644 crates/captions/tests/it/main.rs rename crates/captions/tests/{ => it}/roundtrip.rs (100%) rename crates/captions/tests/{ => it}/ttml_body_timing.rs (100%) rename crates/captions/tests/{ => it}/ttml_speakers.rs (100%) rename crates/codecs/tests/{ => it}/aac_channels_oracle.rs (99%) rename crates/codecs/tests/{ => it}/adts_file.rs (100%) rename crates/codecs/tests/{ => it}/common/mod.rs (100%) rename crates/codecs/tests/{ => it}/draft_decoders.rs (99%) rename crates/codecs/tests/{ => it}/eac3_oracle.rs (99%) rename crates/codecs/tests/{ => it}/frame_color_oracle.rs (99%) rename crates/codecs/tests/{ => it}/h264_draft.rs (99%) create mode 100644 crates/codecs/tests/it/main.rs rename crates/codecs/tests/{ => it}/matroska_audio_oracle.rs (99%) rename crates/codecs/tests/{ => it}/media_oracle.rs (99%) rename crates/codecs/tests/{ => it}/mkv_mp1_oracle.rs (99%) rename crates/codecs/tests/{ => it}/mp4_oracle.rs (99%) rename crates/codecs/tests/{ => it}/mp4_rtmd.rs (100%) rename crates/codecs/tests/{ => it}/mpeg_oracle.rs (99%) rename crates/codecs/tests/{ => it}/mxf_oracle.rs (99%) rename crates/codecs/tests/{ => it}/ogg_oracle.rs (99%) rename crates/codecs/tests/{ => it}/rf64_metadata.rs (100%) rename crates/codecs/tests/{ => it}/y4m_file.rs (100%) create mode 100644 crates/dng/tests/it/main.rs rename crates/dng/tests/{ => it}/oracle_libraw.rs (100%) rename crates/dng/tests/{ => it}/roundtrip.rs (100%) rename crates/dnx/tests/{ => it}/common/mod.rs (100%) rename crates/dnx/tests/{ => it}/fuzz_hostile.rs (54%) create mode 100644 crates/dnx/tests/it/main.rs rename crates/dnx/tests/{ => it}/oracle_decode.rs (99%) rename crates/dnx/tests/{ => it}/oracle_encode.rs (99%) rename crates/dnx/tests/{ => it}/perf.rs (95%) rename crates/dnx/tests/{ => it}/roundtrip.rs (100%) create mode 100644 crates/export/tests/it/main.rs rename crates/export/tests/{ => it}/pool_trim.rs (100%) rename crates/export/tests/{ => it}/renderer_reentrancy.rs (98%) rename crates/h264/tests/{ => it}/common/mod.rs (99%) rename crates/h264/tests/{ => it}/conformance.rs (99%) rename crates/h264/tests/{ => it}/headers.rs (99%) create mode 100644 crates/h264/tests/it/main.rs rename crates/h264/tests/{ => it}/perf.rs (91%) rename crates/h264/tests/{ => it}/pps_validation.rs (100%) rename crates/h264/tests/{ => it}/robustness.rs (99%) rename crates/h264enc/tests/{ => it}/common/mod.rs (100%) rename crates/h264enc/tests/{ => it}/crf_level_and_note.rs (98%) rename crates/h264enc/tests/{ => it}/ffmpeg_oracle.rs (99%) create mode 100644 crates/h264enc/tests/it/main.rs rename crates/hevc/tests/{ => it}/common/mod.rs (99%) rename crates/hevc/tests/{ => it}/conformance.rs (99%) create mode 100644 crates/hevc/tests/it/main.rs rename crates/hevc/tests/{ => it}/perf.rs (91%) rename crates/hevc/tests/{ => it}/robustness.rs (99%) rename crates/interchange/tests/{ => it}/aaf_omf.rs (99%) rename crates/interchange/tests/{ => it}/api.rs (99%) rename crates/interchange/tests/{ => it}/edl.rs (99%) rename crates/interchange/tests/{ => it}/fcp7.rs (99%) rename crates/interchange/tests/{ => it}/fcpxml.rs (99%) rename crates/interchange/tests/{ => it}/file_urls.rs (100%) create mode 100644 crates/interchange/tests/it/main.rs rename crates/interchange/tests/{ => it}/media_marker_export.rs (99%) rename crates/interchange/tests/{ => it}/nested_sequence_markers.rs (99%) rename crates/interchange/tests/{ => it}/otio.rs (99%) rename crates/interchange/tests/{ => it}/point_markers.rs (99%) rename crates/interchange/tests/{ => it}/support/mod.rs (100%) rename crates/interchange/tests/{ => it}/uncarried_clips.rs (99%) rename crates/isobmff/tests/{ => it}/common/mod.rs (100%) rename crates/isobmff/tests/{ => it}/fuzz.rs (99%) create mode 100644 crates/isobmff/tests/it/main.rs rename crates/isobmff/tests/{ => it}/oracle_demux.rs (99%) rename crates/isobmff/tests/{ => it}/oracle_mux.rs (99%) rename crates/isobmff/tests/{ => it}/roundtrip.rs (100%) rename crates/media/tests/{ => it}/exr_oracle.rs (100%) rename crates/media/tests/{ => it}/hdr_oracle.rs (100%) create mode 100644 crates/media/tests/it/main.rs rename crates/media/tests/{ => it}/tiff_ycbcr.rs (100%) rename crates/mpeg2v/tests/{ => it}/common/mod.rs (100%) rename crates/mpeg2v/tests/{ => it}/hostile_dims.rs (100%) create mode 100644 crates/mpeg2v/tests/it/main.rs rename crates/mpeg2v/tests/{ => it}/oracle.rs (99%) rename crates/mpeg2v/tests/{ => it}/perf.rs (87%) rename crates/opus/tests/{ => it}/common/mod.rs (86%) create mode 100644 crates/opus/tests/it/main.rs rename crates/opus/tests/{ => it}/oracle.rs (99%) rename crates/opus/tests/{ => it}/robustness.rs (100%) rename crates/opus/tests/{ => it}/vectors.rs (99%) rename crates/platform/tests/{ => it}/common/mod.rs (96%) rename crates/platform/tests/{ => it}/fallback.rs (84%) rename crates/platform/tests/{ => it}/hardware_encode.rs (100%) rename crates/platform/tests/{ => it}/hevc_probe.rs (100%) create mode 100644 crates/platform/tests/it/main.rs rename crates/platform/tests/{ => it}/media_foundation.rs (99%) rename crates/platform/tests/{ => it}/nvenc.rs (100%) rename crates/platform/tests/{ => it}/nvenc_export.rs (95%) rename crates/platform/tests/{ => it}/nvenc_hevc.rs (95%) rename crates/platform/tests/{ => it}/nvenc_hevc_export.rs (97%) rename crates/platform/tests/{ => it}/nvenc_hevc_main10.rs (96%) rename crates/platform/tests/{ => it}/nvenc_hevc_probe.rs (100%) rename crates/platform/tests/{ => it}/nvenc_rgba_input.rs (94%) rename crates/platform/tests/{ => it}/videotoolbox.rs (99%) rename crates/prores/tests/{ => it}/common/mod.rs (100%) rename crates/prores/tests/{ => it}/fuzz.rs (99%) rename crates/prores/tests/{ => it}/fuzz_hostile.rs (100%) create mode 100644 crates/prores/tests/it/main.rs rename crates/prores/tests/{ => it}/oracle_decode.rs (99%) rename crates/prores/tests/{ => it}/oracle_encode.rs (99%) rename crates/prores/tests/{ => it}/perf.rs (95%) rename crates/prores/tests/{ => it}/roundtrip.rs (99%) create mode 100644 crates/render/tests/it/main.rs rename crates/render/tests/{ => it}/median_radius.rs (100%) rename crates/render/tests/{ => it}/noise_frame_cadence.rs (100%) rename crates/render/tests/{ => it}/sharpen_preview_res.rs (100%) rename crates/render/tests/{ => it}/underline_direction.rs (100%) rename crates/ui-egui/tests/{ => it}/audio51_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/audio_fx_editor_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/caption_properties_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/captions_font_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/chrome_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/clip_audio_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/clip_dialogs_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/clipboard_paste_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/color_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/delete_caption_tracks_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/deselect_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_buttons_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/edit_point_menu_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/escape_drag_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/essential_sound_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/export_menu_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/export_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/frame_export_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/graphics_handles_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/graphics_notice_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/graphics_templates_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/graphics_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/interchange_export_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/interface_zoom_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/keyboard_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/keyframes_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/links_ui.rs (100%) create mode 100644 crates/ui-egui/tests/it/main.rs rename crates/ui-egui/tests/{ => it}/marked_playback_position.rs (100%) rename crates/ui-egui/tests/{ => it}/mask_roundness_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/masks_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/media_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/menus_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/meters_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/mixer_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/monitor_geometry.rs (100%) rename crates/ui-egui/tests/{ => it}/multicam_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/native_dialogs_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/nest_editing_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/nest_rendering_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/nested_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/panels_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/par_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/perf_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/playback_clock_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/project_panel_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/properties_scroll_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/remix_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/scripted.rs (99%) rename crates/ui-egui/tests/{ => it}/sequence_tabs_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/sequence_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/settings_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/shared_docking_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/source_drag_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/source_playback_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timecode_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_drop_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_escape_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_gap_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_marquee_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_middle_pan_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_option_drag_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_scroll_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_tabs_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_track_height_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_transition_move_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/timeline_trim_ui.rs (99%) rename crates/ui-egui/tests/{ => it}/track_color_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/track_select_drag_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/transcript_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/transform_handles_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/transition_drop_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/transitions_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/tts_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_control_input.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_dock_resize.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_keyboard_focus.rs (100%) rename crates/ui-egui/tests/{ => it}/unlink_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/view_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/voiceover_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/workspaces_ui.rs (100%) rename crates/vp8/tests/{ => it}/common/mod.rs (94%) rename crates/vp8/tests/{ => it}/conformance.rs (99%) create mode 100644 crates/vp8/tests/it/main.rs rename crates/vp8/tests/{ => it}/robustness.rs (99%) rename crates/vp8/tests/{ => it}/vectors.rs (99%) rename crates/vp9/tests/{ => it}/common/mod.rs (100%) rename crates/vp9/tests/{ => it}/conformance.rs (99%) create mode 100644 crates/vp9/tests/it/main.rs rename crates/vp9/tests/{ => it}/perf.rs (92%) rename crates/vp9/tests/{ => it}/robustness.rs (99%) rename crates/vp9/tests/{ => it}/synthetic.rs (96%) diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..63e594472 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,14 @@ +# `cargo nextest run` (used by `cargo xtask ci`) runs every test in a process of its own, in parallel. + +[test-groups] +# NVENC sessions are a limited resource on consumer GPUs. +nvenc = { max-threads = 1 } + +[[profile.default.overrides]] +filter = "package(filmcraft-platform) and (binary(/nvenc/) or test(/nvenc/))" +test-group = "nvenc" + +# A throughput gate: it measures one core's speed, so nothing else runs beside it. +[[profile.default.overrides]] +filter = "package(filmcraft-render) and binary(mixer_perf)" +threads-required = "num-test-threads" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be6e31cfc..d156ff87f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,11 +31,6 @@ jobs: 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: @@ -51,11 +46,11 @@ jobs: # 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 \ + uses: storytold/craft-common-actions/actions/install-apt-packages@main + with: + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers libasound2-dev - name: Install Rust (stable + clippy, rustfmt, wasm32) @@ -64,6 +59,11 @@ jobs: --target wasm32-unknown-unknown rustup default stable + # `xtask ci` runs the tests with nextest when it is installed. + - uses: taiki-e/install-action@a4d3f3f761dcd95cce1b72cb61cb6d1963438177 # v2.87.30 + with: + tool: cargo-nextest + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 7666423a1..7934c954d 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -4,19 +4,18 @@ # out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) name: Windows ARM64 +# The package build takes about 15 minutes, so it runs nightly on main and on pushes to `release` +# rather than on every push to main: a dependency or runner-image change still shows up within a +# day. PRs that change the Windows packaging itself still run it. on: + schedule: + - cron: "41 4 * * *" push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - "**/build.rs" - .github/workflows/windows-arm64.yml - packaging/windows/** - schedule: - - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up workflow_dispatch: concurrency: diff --git a/Cargo.toml b/Cargo.toml index 341fb7ab3..752532d2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,11 +92,15 @@ too_many_arguments = "allow" needless_range_loop = "allow" type_complexity = "allow" +# Line tables keep file and line in panics and backtraces at a fraction of full debug info's size; +# dependencies need none. [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" # Codec hot loops: debug-build tests (RFC/oracle vectors) are minutes slower below opt-level 3. [profile.dev.package.filmcraft-h264] diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/it/cli.rs similarity index 100% rename from apps/filmcraft-cli/tests/cli.rs rename to apps/filmcraft-cli/tests/it/cli.rs diff --git a/apps/filmcraft-cli/tests/it/main.rs b/apps/filmcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..9bdd14068 --- /dev/null +++ b/apps/filmcraft-cli/tests/it/main.rs @@ -0,0 +1,2 @@ +mod cli; +mod workflows; diff --git a/apps/filmcraft-cli/tests/workflows.rs b/apps/filmcraft-cli/tests/it/workflows.rs similarity index 100% rename from apps/filmcraft-cli/tests/workflows.rs rename to apps/filmcraft-cli/tests/it/workflows.rs diff --git a/apps/filmcraft-web/src/download_pacing.rs b/apps/filmcraft-web/src/download_pacing.rs index 01f9f4212..944e74bd3 100644 --- a/apps/filmcraft-web/src/download_pacing.rs +++ b/apps/filmcraft-web/src/download_pacing.rs @@ -1,4 +1,4 @@ -//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the +//! Spacing of browser downloads (pure, so it is tested natively in `tests/it/download_pacing.rs`; the //! anchor clicks live in [`crate::fs::download`]). //! //! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export diff --git a/apps/filmcraft-web/src/recovery_policy.rs b/apps/filmcraft-web/src/recovery_policy.rs index 8426d3c39..7a2e0fc26 100644 --- a/apps/filmcraft-web/src/recovery_policy.rs +++ b/apps/filmcraft-web/src/recovery_policy.rs @@ -1,5 +1,5 @@ //! When the web recovery snapshot is written or marked clean (pure, so it is tested natively in -//! `tests/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]). +//! `tests/it/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]). //! //! - A dirty project is snapshotted at most every [`INTERVAL_S`] seconds, once per revision. A write //! that fails is retried after the interval, without waiting for another edit (#197). diff --git a/apps/filmcraft-web/tests/download_pacing.rs b/apps/filmcraft-web/tests/it/download_pacing.rs similarity index 87% rename from apps/filmcraft-web/tests/download_pacing.rs rename to apps/filmcraft-web/tests/it/download_pacing.rs index 893f40d3f..4f2ee8d26 100644 --- a/apps/filmcraft-web/tests/download_pacing.rs +++ b/apps/filmcraft-web/tests/it/download_pacing.rs @@ -1,10 +1,10 @@ //! Download spacing. It is pure, so it runs natively even though the rest of the crate is //! wasm32-only. -#[path = "../src/download_pacing.rs"] -mod download_pacing; +#[path = "../../src/download_pacing.rs"] +mod pacing; -use download_pacing::{GAP_MS, Pacer}; +use pacing::{GAP_MS, Pacer}; /// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst. #[test] diff --git a/apps/filmcraft-web/tests/it/main.rs b/apps/filmcraft-web/tests/it/main.rs new file mode 100644 index 000000000..a223d161d --- /dev/null +++ b/apps/filmcraft-web/tests/it/main.rs @@ -0,0 +1,2 @@ +mod download_pacing; +mod recovery_policy; diff --git a/apps/filmcraft-web/tests/recovery_policy.rs b/apps/filmcraft-web/tests/it/recovery_policy.rs similarity index 96% rename from apps/filmcraft-web/tests/recovery_policy.rs rename to apps/filmcraft-web/tests/it/recovery_policy.rs index c60b34d5b..fe64599d7 100644 --- a/apps/filmcraft-web/tests/recovery_policy.rs +++ b/apps/filmcraft-web/tests/it/recovery_policy.rs @@ -1,10 +1,10 @@ //! The web recovery snapshot policy. It is pure, so it runs natively even though the rest of the //! crate is wasm32-only. -#[path = "../src/recovery_policy.rs"] -mod recovery_policy; +#[path = "../../src/recovery_policy.rs"] +mod policy; -use recovery_policy::{Action, INTERVAL_S, Policy}; +use policy::{Action, INTERVAL_S, Policy}; /// #197: a snapshot write that fails is retried after the interval without another edit. #[test] diff --git a/crates/aac/tests/common/mod.rs b/crates/aac/tests/it/common/mod.rs similarity index 100% rename from crates/aac/tests/common/mod.rs rename to crates/aac/tests/it/common/mod.rs diff --git a/crates/aac/tests/decoder_oracle.rs b/crates/aac/tests/it/decoder_oracle.rs similarity index 99% rename from crates/aac/tests/decoder_oracle.rs rename to crates/aac/tests/it/decoder_oracle.rs index 721b94486..b5591a56a 100644 --- a/crates/aac/tests/decoder_oracle.rs +++ b/crates/aac/tests/it/decoder_oracle.rs @@ -1,7 +1,7 @@ //! Decoder accuracy against ffmpeg's decoder on streams produced by ffmpeg's AAC encoder (oracle //! only; skipped when ffmpeg is absent). -mod common; +use crate::common; use common::*; diff --git a/crates/aac/tests/encoder_oracle.rs b/crates/aac/tests/it/encoder_oracle.rs similarity index 99% rename from crates/aac/tests/encoder_oracle.rs rename to crates/aac/tests/it/encoder_oracle.rs index 3cc312ace..d73abdbb8 100644 --- a/crates/aac/tests/encoder_oracle.rs +++ b/crates/aac/tests/it/encoder_oracle.rs @@ -1,6 +1,6 @@ //! Encoder quality/bitrate tests using ffmpeg as an external decoding oracle (skipped if absent). -mod common; +use crate::common; use common::*; use filmcraft_aac::{BitrateMode, EncoderConfig, WindowShape}; diff --git a/crates/aac/tests/it/main.rs b/crates/aac/tests/it/main.rs new file mode 100644 index 000000000..6f4d1e3bd --- /dev/null +++ b/crates/aac/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod decoder_oracle; +mod encoder_oracle; +mod robustness; diff --git a/crates/aac/tests/robustness.rs b/crates/aac/tests/it/robustness.rs similarity index 99% rename from crates/aac/tests/robustness.rs rename to crates/aac/tests/it/robustness.rs index 9c1bb9ea0..fd0e83ee6 100644 --- a/crates/aac/tests/robustness.rs +++ b/crates/aac/tests/it/robustness.rs @@ -1,7 +1,7 @@ //! Fuzz-style robustness: mutated access units, configs and ADTS streams must never panic, and the //! encoder must accept hostile input (NaN, ±inf, > full scale, silence, odd chunk sizes). -mod common; +use crate::common; use common::*; use filmcraft_aac::{AudioSpecificConfig, Decoder, Encoder, EncoderConfig, split_adts}; diff --git a/crates/apv/README.md b/crates/apv/README.md index e84c9f5f8..906e4b4ae 100644 --- a/crates/apv/README.md +++ b/crates/apv/README.md @@ -54,7 +54,7 @@ let apvc: Vec = enc.decoder_config_record(); // APVDecoderConfigurationReco - **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume) and `metadata_cll` (`payloadType == 6`, Content Light-Level Information). - **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient - ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/fuzz.rs` + ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/it/fuzz.rs` across thousands of mutated and random inputs. ### Encoder @@ -67,7 +67,7 @@ let apvc: Vec = enc.decoder_config_record(); // APVDecoderConfigurationReco ## Accuracy (vs FFmpeg as an External Oracle) -`tests/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple +`tests/it/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting `.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `filmcraft_apv::decode_frame`. Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact diff --git a/crates/apv/tests/fuzz.rs b/crates/apv/tests/it/fuzz.rs similarity index 100% rename from crates/apv/tests/fuzz.rs rename to crates/apv/tests/it/fuzz.rs diff --git a/crates/apv/tests/fuzz_hostile.rs b/crates/apv/tests/it/fuzz_hostile.rs similarity index 100% rename from crates/apv/tests/fuzz_hostile.rs rename to crates/apv/tests/it/fuzz_hostile.rs diff --git a/crates/apv/tests/it/main.rs b/crates/apv/tests/it/main.rs new file mode 100644 index 000000000..51c79839a --- /dev/null +++ b/crates/apv/tests/it/main.rs @@ -0,0 +1,3 @@ +mod fuzz; +mod fuzz_hostile; +mod oracle; diff --git a/crates/apv/tests/oracle.rs b/crates/apv/tests/it/oracle.rs similarity index 100% rename from crates/apv/tests/oracle.rs rename to crates/apv/tests/it/oracle.rs diff --git a/crates/audio-dsp/src/effects/mod.rs b/crates/audio-dsp/src/effects/mod.rs index 1a6f02a33..9a6f781cc 100644 --- a/crates/audio-dsp/src/effects/mod.rs +++ b/crates/audio-dsp/src/effects/mod.rs @@ -271,31 +271,37 @@ mod tests { #[test] fn extreme_settings_stay_finite_and_denormal_free() { - for e in effects() { - for setting in [0.0f32, 1.0] { - let mut fx = (e.create)(SR, 2); - for p in e.params { - fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max }); - } - let mut l = noise(3, 24000, 4.0); - let mut r = noise(4, 24000, 4.0); - l[100] = 1e6; - r[200] = -1e6; - fx.process(&mut [&mut l, &mut r]); - assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id); - // Long silence afterwards: output decays without denormals. - fx.reset(); - let mut l = noise(3, 4800, 0.5); - let mut r = noise(4, 4800, 0.5); - fx.process(&mut [&mut l, &mut r]); - for _ in 0..40 { - let mut l = vec![0.0f32; 48000]; - let mut r = vec![0.0f32; 48000]; - fx.process(&mut [&mut l, &mut r]); - assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id); - } + // One thread per effect. The tail stays 40 s long: delays, reverbs and graphic EQs still + // produce output in its last second, some of it within 3x of the smallest normal f32. + std::thread::scope(|scope| { + for e in effects() { + scope.spawn(move || { + for setting in [0.0f32, 1.0] { + let mut fx = (e.create)(SR, 2); + for p in e.params { + fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max }); + } + let mut l = noise(3, 24000, 4.0); + let mut r = noise(4, 24000, 4.0); + l[100] = 1e6; + r[200] = -1e6; + fx.process(&mut [&mut l, &mut r]); + assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id); + // Long silence afterwards: output decays without denormals. + fx.reset(); + let mut l = noise(3, 4800, 0.5); + let mut r = noise(4, 4800, 0.5); + fx.process(&mut [&mut l, &mut r]); + for _ in 0..40 { + let mut l = vec![0.0f32; 48000]; + let mut r = vec![0.0f32; 48000]; + fx.process(&mut [&mut l, &mut r]); + assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id); + } + } + }); } - } + }); } #[test] diff --git a/crates/av1/README.md b/crates/av1/README.md index 3045e9d37..8d29c1eeb 100644 --- a/crates/av1/README.md +++ b/crates/av1/README.md @@ -43,12 +43,12 @@ reference) is used only as an external test oracle. ## Accuracy -`tests/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the +`tests/it/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the in-loop filters; 64×64 and 128×128 superblocks; intra edge filter on and off) and compares every decoded frame with libdav1d: **bit-exact** on all 13 fixtures (8- and 10-bit 4:2:0; 128×128 to 1280×720 including odd sizes; testsrc2, mandelbrot, gradients and noise; presets 1–8). -`tests/conformance.rs` downloads libaom's conformance test vectors on first use +`tests/it/conformance.rs` downloads libaom's conformance test vectors on first use (storage.googleapis.com/aom-test-data) and compares with libdav1d: `av1-1-b8-02-allintra` (39 frames, deblocking + CDEF + self-guided restoration), `05-mv`, `06-mfmv`, `24-monochrome` and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored @@ -58,8 +58,8 @@ and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored with scaled inter-layer prediction), `24-monochrome` (8 and 10-bit) and `16-intra_only-intrabc-extreme-dv` (1080p intra block copy), `23-film_grain-50` (8 and 10-bit). -`tests/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with -libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction, +`tests/it/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with +libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/it/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction, OBMC / warped motion, motion-field projection, all loop filters; presets 3–8, 8- and 10-bit, odd sizes), plus super-resolution (denominator 12) and film-grain streams, and compares every frame with libdav1d: **bit-exact** on all 6 fixtures. @@ -76,13 +76,13 @@ independent). Results never depend on scheduling (the oracle tests compare 1 thr and libdav1d). With frame threads, pictures may be returned by a later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it. -`tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame +`tests/it/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame (`Decoder::stats()`) and all-core fps with its per-stage ms/frame for 1080p fixtures (key frames, GOPs, tiles, low delay) and a single-tile 2160p GOP; `AV1_PERF_ONLY=a,b` picks fixtures. The hot loops (sub-pixel filters, CDEF, inverse transforms, deblocking, entropy decoding) are written for auto-vectorisation. Measured 2026-10-01 on a 14-core Apple Silicon Mac (release build, SVT-AV1 preset 8 1080p -fixtures from `tests/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame / +fixtures from `tests/it/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame / post-filter threading. The machine was heavily loaded by other builds during both runs (load average 200–400), so the all-core wall figures understate what an idle machine reaches and are noisy; single-threaded figures use thread CPU time and are comparable. diff --git a/crates/av1/tests/adhoc.rs b/crates/av1/tests/it/adhoc.rs similarity index 92% rename from crates/av1/tests/adhoc.rs rename to crates/av1/tests/it/adhoc.rs index e140b9768..1595bc257 100644 --- a/crates/av1/tests/adhoc.rs +++ b/crates/av1/tests/it/adhoc.rs @@ -1,7 +1,7 @@ //! Compare an arbitrary IVF file with libdav1d: -//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test adhoc -- --ignored --nocapture` +//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test it adhoc:: -- --ignored --nocapture` -mod common; +use crate::common; use common::*; #[test] diff --git a/crates/av1/tests/common/mod.rs b/crates/av1/tests/it/common/mod.rs similarity index 100% rename from crates/av1/tests/common/mod.rs rename to crates/av1/tests/it/common/mod.rs diff --git a/crates/av1/tests/conformance.rs b/crates/av1/tests/it/conformance.rs similarity index 94% rename from crates/av1/tests/conformance.rs rename to crates/av1/tests/it/conformance.rs index 033f590e2..b70270af0 100644 --- a/crates/av1/tests/conformance.rs +++ b/crates/av1/tests/it/conformance.rs @@ -1,6 +1,6 @@ //! libaom conformance test vectors (downloaded on first use), compared with libdav1d. -mod common; +use crate::common; use common::*; /// Vectors the decoder handles today (more are added as stages land). @@ -17,7 +17,7 @@ fn conformance_vectors() { } /// The wider libaom set (slow: many downloads and decodes): -/// `cargo test -p filmcraft-av1 --test conformance -- --ignored`; `AV1_VECTORS=a,b` overrides it. +/// `cargo test -p filmcraft-av1 --test it conformance:: -- --ignored`; `AV1_VECTORS=a,b` overrides it. #[test] #[ignore] fn conformance_vectors_extended() { diff --git a/crates/av1/tests/hostile_dims.rs b/crates/av1/tests/it/hostile_dims.rs similarity index 100% rename from crates/av1/tests/hostile_dims.rs rename to crates/av1/tests/it/hostile_dims.rs diff --git a/crates/av1/tests/it/main.rs b/crates/av1/tests/it/main.rs new file mode 100644 index 000000000..decf05989 --- /dev/null +++ b/crates/av1/tests/it/main.rs @@ -0,0 +1,7 @@ +mod adhoc; +mod common; +mod conformance; +mod hostile_dims; +mod oracle_inter; +mod oracle_intra; +mod perf; diff --git a/crates/av1/tests/oracle_inter.rs b/crates/av1/tests/it/oracle_inter.rs similarity index 99% rename from crates/av1/tests/oracle_inter.rs rename to crates/av1/tests/it/oracle_inter.rs index 06a4d88fe..5d443194f 100644 --- a/crates/av1/tests/oracle_inter.rs +++ b/crates/av1/tests/it/oracle_inter.rs @@ -2,7 +2,7 @@ //! GOPs with hierarchical references, compound prediction, OBMC / warped motion, global motion, //! motion-field projection and all loop filters. -mod common; +use crate::common; use common::*; fn specs() -> Vec { diff --git a/crates/av1/tests/oracle_intra.rs b/crates/av1/tests/it/oracle_intra.rs similarity index 99% rename from crates/av1/tests/oracle_intra.rs rename to crates/av1/tests/it/oracle_intra.rs index 441220f18..d002af67e 100644 --- a/crates/av1/tests/oracle_intra.rs +++ b/crates/av1/tests/it/oracle_intra.rs @@ -1,6 +1,6 @@ //! Intra-frame bit-exactness against libdav1d (run as an external ffmpeg process). -mod common; +use crate::common; use common::*; const NO_FILTERS: &str = "enable-cdef=0:enable-restoration=0:enable-dlf=0:keyint=1"; diff --git a/crates/av1/tests/perf.rs b/crates/av1/tests/it/perf.rs similarity index 97% rename from crates/av1/tests/perf.rs rename to crates/av1/tests/it/perf.rs index 9047605aa..518a0cac3 100644 --- a/crates/av1/tests/perf.rs +++ b/crates/av1/tests/it/perf.rs @@ -1,11 +1,11 @@ //! Decode throughput and per-stage breakdown (ignored; run with -//! `cargo test --release -p filmcraft-av1 --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-av1 --test it perf:: -- --ignored --nocapture`). //! //! The machine may be busy (other builds), so single-threaded work is also measured with the //! thread CPU clock, which is robust to load; wall-clock figures are printed next to the load //! average. -mod common; +use crate::common; use common::*; use filmcraft_av1::{DecodeStats, Decoder, Stage}; use std::time::Instant; diff --git a/crates/captions/README.md b/crates/captions/README.md index 04183352c..8618cadac 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -85,7 +85,7 @@ implementation's code was consulted. - Unit tests per module (timestamps, decoding, sloppy SRT, WebVTT blocks and voices, CEA-608 tables, PAC round trips, known SCC pop-on and roll-up streams, layout). -- `tests/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers, +- `tests/it/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers, settings, STYLE blocks), SCC and MCC (drop-frame and non-drop, gaps and back-to-back captions; MCC also through CEA-708 alone), EBU STL (25 and 29.97 fps, accents, extension blocks), TTML (23.976 / 25 / 29.97 / 59.94, ids, speakers) round-trip frame-exactly; DFXP round-trips after diff --git a/crates/captions/tests/it/main.rs b/crates/captions/tests/it/main.rs new file mode 100644 index 000000000..d890ff913 --- /dev/null +++ b/crates/captions/tests/it/main.rs @@ -0,0 +1,3 @@ +mod roundtrip; +mod ttml_body_timing; +mod ttml_speakers; diff --git a/crates/captions/tests/roundtrip.rs b/crates/captions/tests/it/roundtrip.rs similarity index 100% rename from crates/captions/tests/roundtrip.rs rename to crates/captions/tests/it/roundtrip.rs diff --git a/crates/captions/tests/ttml_body_timing.rs b/crates/captions/tests/it/ttml_body_timing.rs similarity index 100% rename from crates/captions/tests/ttml_body_timing.rs rename to crates/captions/tests/it/ttml_body_timing.rs diff --git a/crates/captions/tests/ttml_speakers.rs b/crates/captions/tests/it/ttml_speakers.rs similarity index 100% rename from crates/captions/tests/ttml_speakers.rs rename to crates/captions/tests/it/ttml_speakers.rs diff --git a/crates/codecs/tests/aac_channels_oracle.rs b/crates/codecs/tests/it/aac_channels_oracle.rs similarity index 99% rename from crates/codecs/tests/aac_channels_oracle.rs rename to crates/codecs/tests/it/aac_channels_oracle.rs index 5f850ac3e..43bcf6a65 100644 --- a/crates/codecs/tests/aac_channels_oracle.rs +++ b/crates/codecs/tests/it/aac_channels_oracle.rs @@ -3,7 +3,7 @@ //! both 7.1 (eight channels) and 11 is 6.1 (seven). Each channel carries its own tone, so a //! dropped or swapped channel shows up against ffmpeg's decode. -mod common; +use crate::common; use std::io::Cursor; diff --git a/crates/codecs/tests/adts_file.rs b/crates/codecs/tests/it/adts_file.rs similarity index 100% rename from crates/codecs/tests/adts_file.rs rename to crates/codecs/tests/it/adts_file.rs diff --git a/crates/codecs/tests/common/mod.rs b/crates/codecs/tests/it/common/mod.rs similarity index 100% rename from crates/codecs/tests/common/mod.rs rename to crates/codecs/tests/it/common/mod.rs diff --git a/crates/codecs/tests/draft_decoders.rs b/crates/codecs/tests/it/draft_decoders.rs similarity index 99% rename from crates/codecs/tests/draft_decoders.rs rename to crates/codecs/tests/it/draft_decoders.rs index bec6476f9..b656dea50 100644 --- a/crates/codecs/tests/draft_decoders.rs +++ b/crates/codecs/tests/it/draft_decoders.rs @@ -2,7 +2,7 @@ //! off by default, approximate frames only reach draft requests, and decoding for an export after //! draft playback (same source, same GOP cache) still matches ffmpeg bit for bit. -mod common; +use crate::common; use common::*; use filmcraft_media::FrameRequest; diff --git a/crates/codecs/tests/eac3_oracle.rs b/crates/codecs/tests/it/eac3_oracle.rs similarity index 99% rename from crates/codecs/tests/eac3_oracle.rs rename to crates/codecs/tests/it/eac3_oracle.rs index 7d875d49c..e75476db7 100644 --- a/crates/codecs/tests/eac3_oracle.rs +++ b/crates/codecs/tests/it/eac3_oracle.rs @@ -2,7 +2,7 @@ //! and muxes the files and decodes them as the oracle. Zero-bit mantissas carry decoder-specific //! dither (A/52 §7.3.4), so the criterion is SNR against ffmpeg's decode, sample-aligned. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/frame_color_oracle.rs b/crates/codecs/tests/it/frame_color_oracle.rs similarity index 99% rename from crates/codecs/tests/frame_color_oracle.rs rename to crates/codecs/tests/it/frame_color_oracle.rs index 7da3baf09..15740fc3e 100644 --- a/crates/codecs/tests/frame_color_oracle.rs +++ b/crates/codecs/tests/it/frame_color_oracle.rs @@ -2,7 +2,7 @@ //! MP4 without a `colr` box unless asked, so for most files the bitstream is the only place the //! primaries are described: stream info already read them, the decoded frames reported BT.709. -mod common; +use crate::common; use common::*; use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer}; diff --git a/crates/codecs/tests/h264_draft.rs b/crates/codecs/tests/it/h264_draft.rs similarity index 99% rename from crates/codecs/tests/h264_draft.rs rename to crates/codecs/tests/it/h264_draft.rs index 81e8c3bb8..82721ee30 100644 --- a/crates/codecs/tests/h264_draft.rs +++ b/crates/codecs/tests/it/h264_draft.rs @@ -2,7 +2,7 @@ //! by default, its approximate frames only ever reach draft requests, and decoding for an export //! after draft playback (same source, same GOP cache) still matches ffmpeg bit for bit. -mod common; +use crate::common; use common::*; use filmcraft_media::FrameRequest; diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs new file mode 100644 index 000000000..679dcaa6b --- /dev/null +++ b/crates/codecs/tests/it/main.rs @@ -0,0 +1,17 @@ +mod aac_channels_oracle; +mod adts_file; +mod common; +mod draft_decoders; +mod eac3_oracle; +mod frame_color_oracle; +mod h264_draft; +mod matroska_audio_oracle; +mod media_oracle; +mod mkv_mp1_oracle; +mod mp4_oracle; +mod mp4_rtmd; +mod mpeg_oracle; +mod mxf_oracle; +mod ogg_oracle; +mod rf64_metadata; +mod y4m_file; diff --git a/crates/codecs/tests/matroska_audio_oracle.rs b/crates/codecs/tests/it/matroska_audio_oracle.rs similarity index 99% rename from crates/codecs/tests/matroska_audio_oracle.rs rename to crates/codecs/tests/it/matroska_audio_oracle.rs index 3ded7af07..c2cfbd45e 100644 --- a/crates/codecs/tests/matroska_audio_oracle.rs +++ b/crates/codecs/tests/it/matroska_audio_oracle.rs @@ -4,7 +4,7 @@ //! reads it, a tone must stay as smooth as ffmpeg's decode of it: no holes or overlaps between //! packets. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/it/media_oracle.rs similarity index 99% rename from crates/codecs/tests/media_oracle.rs rename to crates/codecs/tests/it/media_oracle.rs index da95c5eb6..5091c1dc3 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/it/media_oracle.rs @@ -4,7 +4,7 @@ //! exactly, JPEG within a few levels, high bit depths within one 8-bit step), and the BWF //! TimeReference must give the start timecode. -mod common; +use crate::common; use std::path::Path; use std::sync::Arc; diff --git a/crates/codecs/tests/mkv_mp1_oracle.rs b/crates/codecs/tests/it/mkv_mp1_oracle.rs similarity index 99% rename from crates/codecs/tests/mkv_mp1_oracle.rs rename to crates/codecs/tests/it/mkv_mp1_oracle.rs index 05d9810fb..aa142457c 100644 --- a/crates/codecs/tests/mkv_mp1_oracle.rs +++ b/crates/codecs/tests/it/mkv_mp1_oracle.rs @@ -2,7 +2,7 @@ //! installed encoder writes Layer I, so the frames are synthetic (valid random Layer I frames, //! as in `crates/mpa/tests/oracle.rs`) and ffmpeg only remuxes them into Matroska and decodes. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/it/mp4_oracle.rs similarity index 99% rename from crates/codecs/tests/mp4_oracle.rs rename to crates/codecs/tests/it/mp4_oracle.rs index cbb71ba0e..892598daf 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/it/mp4_oracle.rs @@ -2,7 +2,7 @@ //! priming), and reads at another sample rate must land on the same source samples as the native //! decode, however the requests are cut. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/mp4_rtmd.rs b/crates/codecs/tests/it/mp4_rtmd.rs similarity index 100% rename from crates/codecs/tests/mp4_rtmd.rs rename to crates/codecs/tests/it/mp4_rtmd.rs diff --git a/crates/codecs/tests/mpeg_oracle.rs b/crates/codecs/tests/it/mpeg_oracle.rs similarity index 99% rename from crates/codecs/tests/mpeg_oracle.rs rename to crates/codecs/tests/it/mpeg_oracle.rs index e6d5a39dd..bf58294e8 100644 --- a/crates/codecs/tests/mpeg_oracle.rs +++ b/crates/codecs/tests/it/mpeg_oracle.rs @@ -4,7 +4,7 @@ //! random seeks give the frames of the sequential decode. Audio: LPCM sample-exact, MPEG audio //! and AAC within a float-decoder tolerance, starting at the same sample. -mod common; +use crate::common; use std::path::{Path, PathBuf}; diff --git a/crates/codecs/tests/mxf_oracle.rs b/crates/codecs/tests/it/mxf_oracle.rs similarity index 99% rename from crates/codecs/tests/mxf_oracle.rs rename to crates/codecs/tests/it/mxf_oracle.rs index 5a965b814..23d882755 100644 --- a/crates/codecs/tests/mxf_oracle.rs +++ b/crates/codecs/tests/it/mxf_oracle.rs @@ -2,7 +2,7 @@ //! oracle. Our decode must match frame for frame (H.264 bit-exact, VC-3 ±2, ProRes ±1 LSB), seek //! to random frames exactly, and give sample-exact PCM. -mod common; +use crate::common; use std::path::{Path, PathBuf}; diff --git a/crates/codecs/tests/ogg_oracle.rs b/crates/codecs/tests/it/ogg_oracle.rs similarity index 99% rename from crates/codecs/tests/ogg_oracle.rs rename to crates/codecs/tests/it/ogg_oracle.rs index 939407756..be02870ed 100644 --- a/crates/codecs/tests/ogg_oracle.rs +++ b/crates/codecs/tests/it/ogg_oracle.rs @@ -4,7 +4,7 @@ //! `filmcraft-opus`'s own oracle; Vorbis: SNR ≥ 60 dB), and any random window must equal the //! continuous decode (granule-position seeking with pre-roll). -mod common; +use crate::common; use std::path::{Path, PathBuf}; diff --git a/crates/codecs/tests/rf64_metadata.rs b/crates/codecs/tests/it/rf64_metadata.rs similarity index 100% rename from crates/codecs/tests/rf64_metadata.rs rename to crates/codecs/tests/it/rf64_metadata.rs diff --git a/crates/codecs/tests/y4m_file.rs b/crates/codecs/tests/it/y4m_file.rs similarity index 100% rename from crates/codecs/tests/y4m_file.rs rename to crates/codecs/tests/it/y4m_file.rs diff --git a/crates/codecs/tests/pool_bitexact.rs b/crates/codecs/tests/pool_bitexact.rs index a90ec658e..1261e9a2b 100644 --- a/crates/codecs/tests/pool_bitexact.rs +++ b/crates/codecs/tests/pool_bitexact.rs @@ -3,6 +3,7 @@ //! primed with buffers full of 0xAA before decoding, and every frame must still match ffmpeg bit //! for bit. (Its own process: the pool is global.) +#[path = "it/common/mod.rs"] mod common; use std::sync::Arc; diff --git a/crates/dng/README.md b/crates/dng/README.md index 4c2b1e7e9..c8678c1d3 100644 --- a/crates/dng/README.md +++ b/crates/dng/README.md @@ -73,14 +73,14 @@ restart intervals that split a line, and MXF-wrapped CinemaDNG. ## Tests -- `tests/roundtrip.rs`: container parsing in both byte orders with and without a preview IFD; +- `tests/it/roundtrip.rs`: container parsing in both byte orders with and without a preview IFD; uncompressed samples bit-exact at 8–16 bits, strips and tiles; lossless JPEG bit-exact for every predictor and component count, full-swing 16-bit data (every difference category, SSSS = 16), restarts and point transform; developed values against hand-computed ones (black / white, white balance, ForwardMatrix, BaselineExposure, ActiveArea, DefaultCrop, LinearRaw); CinemaDNG FrameRate / TimeCodes / ReelName; hostile files (every truncation, 12 000 mutations under `catch_unwind`, cyclic IFDs, huge sizes, bad offsets, zero denominators). -- `tests/oracle_libraw.rs`: LibRaw's `unprocessed_raw` reads the same samples from our synthetic +- `tests/it/oracle_libraw.rs`: LibRaw's `unprocessed_raw` reads the same samples from our synthetic files (uncompressed 8–16-bit in both byte orders, lossless JPEG predictors 1–7 with one and two components). Skipped when it is not installed. - Unit tests: matrix maths, the published sRGB matrix, McCamy temperatures, calibration diff --git a/crates/dng/tests/it/main.rs b/crates/dng/tests/it/main.rs new file mode 100644 index 000000000..fd498d961 --- /dev/null +++ b/crates/dng/tests/it/main.rs @@ -0,0 +1,2 @@ +mod oracle_libraw; +mod roundtrip; diff --git a/crates/dng/tests/oracle_libraw.rs b/crates/dng/tests/it/oracle_libraw.rs similarity index 100% rename from crates/dng/tests/oracle_libraw.rs rename to crates/dng/tests/it/oracle_libraw.rs diff --git a/crates/dng/tests/roundtrip.rs b/crates/dng/tests/it/roundtrip.rs similarity index 100% rename from crates/dng/tests/roundtrip.rs rename to crates/dng/tests/it/roundtrip.rs diff --git a/crates/dnx/README.md b/crates/dnx/README.md index 596ea9fa2..9f4656817 100644 --- a/crates/dnx/README.md +++ b/crates/dnx/README.md @@ -59,7 +59,7 @@ first frame header to label the stream. 12-bit coefficient domain is ¼ of the orthonormal DCT, as Table 14's 13-bit IDCT input range for 12-bit samples implies) and 1 otherwise. - Robustness: every offset and size is validated, run lengths past coefficient 63 are rejected, - and corrupted or truncated streams return `Err` without panicking (`tests/roundtrip.rs`). + and corrupted or truncated streams return `Err` without panicking (`tests/it/roundtrip.rs`). ### Where we follow ffmpeg rather than the letter of the standard @@ -100,7 +100,7 @@ the prose of §7.3.1.2; we follow the prose (normative text takes precedence). ## Accuracy (vs ffmpeg as an external oracle) -`tests/oracle_decode.rs` decodes 22 fixtures made by ffmpeg's VC-3 encoder: CIDs 1235, 1237, +`tests/it/oracle_decode.rs` decodes 22 fixtures made by ffmpeg's VC-3 encoder: CIDs 1235, 1237, 1238, 1241, 1242, 1243, 1244, 1250, 1251, 1252, 1253, 1258, 1259 and DNxHR LB/SQ/HQ/HQX/444 (Y′CbCr and RGB), with testsrc2, mandelbrot and heavy noise, at 1920×1080, 1280×720, 960×720, 1440×1080 (thin raster, interlaced), 1000×562, 640×360, 256×120 and 3840×2160. Compared with @@ -111,7 +111,7 @@ decoder evaluates the exact IDCT of equation 8.3, so decode is **not bit-exact, precision** of ffmpeg. (ffmpeg wraps rather than clips 10-bit reconstructions that overflow 1023, so the noise fixtures keep their samples inside 24…231 before encoding.) -`tests/oracle_encode.rs` encodes 1920×1080 frames with every profile and writes them into a +`tests/it/oracle_encode.rs` encodes 1920×1080 frames with every profile and writes them into a minimal test-only MOV. ffmpeg decodes them with `-xerror`, without errors, and agrees with our decoder within ±2 (90–99% identical). Luma PSNR against the source: HQ 58.8 dB (mandelbrot) and 66.3 dB (testsrc2), SQ 57.5 dB, LB 56.1 dB, HQX 10-bit 67.5 dB, HQX 12-bit 80.2 dB, 444 10-bit @@ -119,7 +119,7 @@ decoder within ±2 (90–99% identical). Luma PSNR against the source: HQ 58.8 d ## Performance -`cargo test --release -p filmcraft-dnx --test perf -- --ignored --nocapture` on an M4 Pro +`cargo test --release -p filmcraft-dnx --test it perf:: -- --ignored --nocapture` on an M4 Pro (14 cores) while the machine was **heavily overloaded** by other jobs (load average ≈ 190). Under the same load `filmcraft-prores`' perf test measured 30 fps threaded / 11.8 fps single for a 1080p HQ frame, against 318 / 53 fps in its README on a less loaded machine, so these are diff --git a/crates/dnx/tests/common/mod.rs b/crates/dnx/tests/it/common/mod.rs similarity index 100% rename from crates/dnx/tests/common/mod.rs rename to crates/dnx/tests/it/common/mod.rs diff --git a/crates/dnx/tests/fuzz_hostile.rs b/crates/dnx/tests/it/fuzz_hostile.rs similarity index 54% rename from crates/dnx/tests/fuzz_hostile.rs rename to crates/dnx/tests/it/fuzz_hostile.rs index b3eee0037..8ff02e27c 100644 --- a/crates/dnx/tests/fuzz_hostile.rs +++ b/crates/dnx/tests/it/fuzz_hostile.rs @@ -58,27 +58,34 @@ fn run(d: &[u8], label: &str) { /// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). const DEADLINE_SECS: u64 = 600; +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=1500` runs all of it (see [`Depth`]). #[test] fn hostile_frames_never_panic() { filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); } fn sweep() { + let depth = Depth::from_env(); let seeds = seeds(); assert!(!seeds.is_empty()); for (si, seed) in seeds.iter().enumerate() { - let stride = (seed.len() / 400).max(1); - for n in (0..seed.len()).step_by(stride) { + for n in depth.truncations(seed.len()) { run(&seed[..n], &format!("seed {si} truncate {n}")); } // The header (and the start of the scan-line index): every byte to extreme values, every bit. for p in 0..seed.len().min(0x300) { - for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } let mut d = seed.clone(); d[p] = v; run(&d, &format!("seed {si} byte {p:#x}={v:#x}")); } for b in 0..8 { + if !depth.bit(p, b) { + continue; + } let mut d = seed.clone(); d[p] ^= 1 << b; run(&d, &format!("seed {si} flip {p:#x}.{b}")); @@ -93,8 +100,8 @@ fn sweep() { 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 { + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { let mut d = seed.clone(); for _ in 0..1 + rng.below(8) { let p = rng.below(d.len()); @@ -104,7 +111,61 @@ fn sweep() { d[p] = rng.next() as u8; } } - run(&d, &format!("seed {si} random {i}")); + if depth.round(i) { + run(&d, &format!("seed {si} random {i}")); + } } } } + +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 400 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 1500 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 400, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=1500 runs everything)"); + Depth { full: false, truncations: 400, rounds: 1500, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} diff --git a/crates/dnx/tests/it/main.rs b/crates/dnx/tests/it/main.rs new file mode 100644 index 000000000..c12ebd099 --- /dev/null +++ b/crates/dnx/tests/it/main.rs @@ -0,0 +1,6 @@ +mod common; +mod fuzz_hostile; +mod oracle_decode; +mod oracle_encode; +mod perf; +mod roundtrip; diff --git a/crates/dnx/tests/oracle_decode.rs b/crates/dnx/tests/it/oracle_decode.rs similarity index 99% rename from crates/dnx/tests/oracle_decode.rs rename to crates/dnx/tests/it/oracle_decode.rs index 476ee68d1..a9da675c8 100644 --- a/crates/dnx/tests/oracle_decode.rs +++ b/crates/dnx/tests/it/oracle_decode.rs @@ -1,6 +1,6 @@ //! Decode ffmpeg-made DNxHD / DNxHR fixtures and compare with ffmpeg's own decode. -mod common; +use crate::common; use common::*; use filmcraft_dnx::{DecodeOptions, decode_frame_with}; diff --git a/crates/dnx/tests/oracle_encode.rs b/crates/dnx/tests/it/oracle_encode.rs similarity index 99% rename from crates/dnx/tests/oracle_encode.rs rename to crates/dnx/tests/it/oracle_encode.rs index ca64941df..4d5702698 100644 --- a/crates/dnx/tests/oracle_encode.rs +++ b/crates/dnx/tests/it/oracle_encode.rs @@ -1,6 +1,6 @@ //! Encode DNxHR with our encoder, decode with ffmpeg (`-xerror`) and with our decoder. -mod common; +use crate::common; use common::*; use filmcraft_dnx::{ChromaFormat, Encoder, EncoderConfig, Profile, decode_frame}; diff --git a/crates/dnx/tests/perf.rs b/crates/dnx/tests/it/perf.rs similarity index 95% rename from crates/dnx/tests/perf.rs rename to crates/dnx/tests/it/perf.rs index 255f1961c..befd20a48 100644 --- a/crates/dnx/tests/perf.rs +++ b/crates/dnx/tests/it/perf.rs @@ -1,4 +1,4 @@ -//! Throughput: `cargo test --release -p filmcraft-dnx --test perf -- --ignored --nocapture`. +//! Throughput: `cargo test --release -p filmcraft-dnx --test it perf:: -- --ignored --nocapture`. use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with}; use std::time::Instant; diff --git a/crates/dnx/tests/roundtrip.rs b/crates/dnx/tests/it/roundtrip.rs similarity index 100% rename from crates/dnx/tests/roundtrip.rs rename to crates/dnx/tests/it/roundtrip.rs diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 2e6eb8a99..57a07791c 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -155,11 +155,13 @@ fn files_in(dir: &Path) -> Vec { v } -/// Every built-in preset: a quarter second of the demo sequence (seven frames at 23.976) exported +/// Every built-in preset: 1.0 to 1.08 s of the demo sequence (three frames at 23.976) exported /// with `file.exportMedia {preset}`; ffprobe checks container, codec, profile, frame size, frame -/// rate and audio format, and the data rate where the codec fixes it. +/// rate and audio format, and the data rate where the codec fixes it. Three frames are enough for +/// all of these, and each extra frame costs a 4K H.264 encode in five presets. #[test] fn every_builtin_preset_exports_and_ffprobe_confirms() { + let (start, end) = (1.0, 1.08); let mut s = demo(); let presets = s.execute("export.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().clone(); assert!(presets.len() >= 24); @@ -169,7 +171,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { let sub = dir.0.join(format!("p{}", filmcraft_export::presets::preset_key(name))); std::fs::create_dir_all(&sub).unwrap(); let path = sub.join("Demo").to_string_lossy().to_string(); - let params = json!({"preset": name, "path": path, "range": "custom", "startSeconds": 1.0, "endSeconds": 1.25, "wait": true}); + let params = json!({"preset": name, "path": path, "range": "custom", "startSeconds": start, "endSeconds": end, "wait": true}); let frames = s.execute("export.resolve", params.clone()).unwrap()["frames"].as_u64().unwrap(); let r = s.execute("file.exportMedia", params).unwrap_or_else(|e| panic!("{name}: {e}")); let out = r["path"].as_str().unwrap().to_string(); @@ -182,7 +184,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx" | "exr") { let files = files_in(&sub); let ext = Path::new(&out).extension().unwrap().to_string_lossy().to_string(); - assert_eq!(frames, 7, "frames 23..=29 at 23.976 fps"); + assert_eq!(frames, 3, "frames 23..=25 at 23.976 fps"); let want: Vec = (0..frames).map(|i| format!("Demo{i:03}.{ext}")).collect(); assert_eq!(files, want, "{name}"); if let Some(j) = probe(&sub.join(&want[0]).to_string_lossy()) { @@ -218,7 +220,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert_eq!(a["codec_name"], codec, "{name}"); assert_eq!(a["sample_rate"], "48000", "{name}"); assert_eq!(a["channels"], 2, "{name}"); - assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]); + assert!((num(&j["format"]["duration"]) - (end - start)).abs() < 0.01, "{name}: {}", j["format"]["duration"]); continue; } "aac" => { @@ -228,7 +230,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}"); let a = stream(&j, "audio").unwrap(); assert_eq!((a["codec_name"].as_str(), a["sample_rate"].as_str(), a["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)), "{name}"); - assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.03, "{name}: {}", j["format"]["duration"]); + assert!((num(&j["format"]["duration"]) - (end - start)).abs() < 0.03, "{name}: {}", j["format"]["duration"]); continue; } "gif" => { @@ -332,9 +334,6 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { } } -/// H.264 bitrate control over three seconds of the demo: the adaptive Match Source preset -/// targets width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's -/// measured video bitrate stays within the VBR maximum and close to the target. #[test] fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() { let mut s = demo(); @@ -354,6 +353,12 @@ fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() { assert_eq!(&std::fs::read(&m4a).unwrap()[4..8], b"ftyp"); } +/// H.264 bitrate control over one second of the demo: the adaptive Match Source preset targets +/// width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's measured +/// video bitrate stays within the VBR maximum and close to the target. 0.5 to 1.5 s holds the +/// second half of the one-second fade in from black, where every frame changes and each preset +/// spends its whole budget; the shot after it is simple enough that both 1080p presets land at the +/// same bitrate, far under their targets. #[test] fn h264_presets_hit_their_bitrates() { let mut s = demo(); @@ -366,7 +371,7 @@ fn h264_presets_hit_their_bitrates() { let path = dir.path(&format!("{}.mp4", filmcraft_export::presets::preset_key(preset))); s.execute( "file.exportMedia", - json!({"preset": preset, "path": path, "range": "custom", "startSeconds": 0.0, "endSeconds": 3.0, "audio": false, "wait": true}), + json!({"preset": preset, "path": path, "range": "custom", "startSeconds": 0.5, "endSeconds": 1.5, "audio": false, "wait": true}), ) .unwrap(); let Some(j) = probe(&path) else { return }; diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs new file mode 100644 index 000000000..3452f185c --- /dev/null +++ b/crates/export/tests/it/main.rs @@ -0,0 +1,2 @@ +mod pool_trim; +mod renderer_reentrancy; diff --git a/crates/export/tests/pool_trim.rs b/crates/export/tests/it/pool_trim.rs similarity index 100% rename from crates/export/tests/pool_trim.rs rename to crates/export/tests/it/pool_trim.rs diff --git a/crates/export/tests/renderer_reentrancy.rs b/crates/export/tests/it/renderer_reentrancy.rs similarity index 98% rename from crates/export/tests/renderer_reentrancy.rs rename to crates/export/tests/it/renderer_reentrancy.rs index fc6c235bc..2c3a8bd8a 100644 --- a/crates/export/tests/renderer_reentrancy.rs +++ b/crates/export/tests/it/renderer_reentrancy.rs @@ -143,7 +143,7 @@ fn nested_rayon_renderers_finish_every_export_path() { } for case in ["png", "gif", "stepped", "overlap-audio"] { let mut child = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "nested_rayon_renderers_finish_every_export_path", "--nocapture"]) + .args(["--exact", "renderer_reentrancy::nested_rayon_renderers_finish_every_export_path", "--nocapture"]) .env(CHILD, case) .spawn() .unwrap(); diff --git a/crates/h264/README.md b/crates/h264/README.md index 21e7a10f1..634a2977f 100644 --- a/crates/h264/README.md +++ b/crates/h264/README.md @@ -108,7 +108,7 @@ with ffmpeg + libx264; tests print a message and skip when `/opt/homebrew/bin/ff explicit bi-prediction. The expected output is known exactly and is also checked against ffmpeg. - Robustness: avcC input with pts round trip; randomly corrupted, truncated and garbage input never panics or deadlocks (single- and multi-threaded). -- Conformance (`tests/conformance.rs`): every fixture is decoded single-threaded, with 3 threads +- Conformance (`tests/it/conformance.rs`): every fixture is decoded single-threaded, with 3 threads and with every core, and compared byte-for-byte with `ffmpeg -f rawvideo -pix_fmt yuv420p`; on mismatch the first differing frame, plane, sample position and macroblock are reported. Output pts/POC order is checked too, and that no picture is flagged draft without draft mode. Draft @@ -164,12 +164,12 @@ encoder is unavailable. | vt_high | testsrc2+noise 640x360 | VideoToolbox, High | | vt_high_1080p | testsrc2 1920x1080 | VideoToolbox, High 8 Mbit/s | -`cargo test --release -p filmcraft-h264 --test conformance coverage_report -- --ignored --nocapture` +`cargo test --release -p filmcraft-h264 --test it conformance::coverage_report -- --ignored --nocapture` prints which coding tools each fixture exercises. ## Performance -`cargo test --release -p filmcraft-h264 --test perf -- --ignored --nocapture` (1080p High profile, +`cargo test --release -p filmcraft-h264 --test it perf:: -- --ignored --nocapture` (1080p High profile, CABAC, B-pyramid, 120 frames, 8.8 Mbit/s; bit-exactness is verified first). Measured on an Apple M4 Pro (14 cores) while the machine was heavily shared (load average 40-180), so these are lower bounds: diff --git a/crates/h264/tests/fuzz_hostile.rs b/crates/h264/tests/fuzz_hostile.rs index d45af4148..92f7bdc6c 100644 --- a/crates/h264/tests/fuzz_hostile.rs +++ b/crates/h264/tests/fuzz_hostile.rs @@ -1,6 +1,7 @@ //! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and //! random mutation of small libx264 streams must never panic or run away. Skips without ffmpeg. +#[path = "it/common/mod.rs"] mod common; use std::panic::{AssertUnwindSafe, catch_unwind}; @@ -82,6 +83,7 @@ fn run_all(d: &[u8], label: &str) { /// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). const DEADLINE_SECS: u64 = 600; +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=400` runs all of it (see [`Depth`]). #[test] fn hostile_streams_never_panic() { filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); @@ -89,6 +91,7 @@ fn hostile_streams_never_panic() { fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("h264 fuzz") else { return }; + let depth = Depth::from_env(); for &(name, args, pix) in SEEDS { if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libx264") { filmcraft_testkit::skip("h264 fuzz", &format!("this ffmpeg has no libx264 encoder (seed {name})")); @@ -97,25 +100,31 @@ fn sweep() { 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) { + for n in depth.truncations(seed.len()) { run_all(&seed[..n], &format!("{name} truncate {n}")); } // SPS/PPS/first slice header region: every byte to a few values, every bit for p in 0..seed.len().min(120) { - for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } let mut d = seed.clone(); d[p] = v; run_all(&d, &format!("{name} byte {p}={v:#x}")); } for b in 0..8 { + if !depth.bit(p, b) { + continue; + } let mut d = seed.clone(); d[p] ^= 1 << b; run_all(&d, &format!("{name} flip {p}.{b}")); } } let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); - for i in 0..iters() { + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { let mut d = seed.clone(); for _ in 0..1 + rng.below(6) { let p = rng.below(d.len()); @@ -125,12 +134,61 @@ fn sweep() { d[p] = rng.next() as u8; } } - run_all(&d, &format!("{name} random {i}")); + if depth.round(i) { + 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) +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 300 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 400 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 300, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=400 runs everything)"); + Depth { full: false, truncations: 300, rounds: 400, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) } diff --git a/crates/h264/tests/common/mod.rs b/crates/h264/tests/it/common/mod.rs similarity index 99% rename from crates/h264/tests/common/mod.rs rename to crates/h264/tests/it/common/mod.rs index e100c3289..2de4d62d2 100644 --- a/crates/h264/tests/common/mod.rs +++ b/crates/h264/tests/it/common/mod.rs @@ -64,7 +64,7 @@ pub const FIXTURES: &[Fixture] = &[ filter: "", args: &["-c:v", "h264_videotoolbox", "-profile:v", "high", "-b:v", "8M"], }, - // performance reference stream (see tests/perf.rs) + // performance reference stream (see tests/it/perf.rs) Fixture { name: "bench_1080p", source: "testsrc2", diff --git a/crates/h264/tests/conformance.rs b/crates/h264/tests/it/conformance.rs similarity index 99% rename from crates/h264/tests/conformance.rs rename to crates/h264/tests/it/conformance.rs index c06694b79..a55d9fe40 100644 --- a/crates/h264/tests/conformance.rs +++ b/crates/h264/tests/it/conformance.rs @@ -1,6 +1,6 @@ //! Bit-exactness against ffmpeg's decoder on libx264-generated fixtures (skipped when ffmpeg is absent). -mod common; +use crate::common; macro_rules! fixture_tests { ($($name:ident),* $(,)?) => { diff --git a/crates/h264/tests/headers.rs b/crates/h264/tests/it/headers.rs similarity index 99% rename from crates/h264/tests/headers.rs rename to crates/h264/tests/it/headers.rs index f0f7a59e3..f27a7ed01 100644 --- a/crates/h264/tests/headers.rs +++ b/crates/h264/tests/it/headers.rs @@ -1,6 +1,6 @@ //! Parameter set / slice header / POC parsing over the fixture streams. -mod common; +use crate::common; use filmcraft_bitstream::{annexb_nals, unescape_rbsp}; use filmcraft_h264::params::{Pps, Sps}; diff --git a/crates/h264/tests/it/main.rs b/crates/h264/tests/it/main.rs new file mode 100644 index 000000000..ceefd95be --- /dev/null +++ b/crates/h264/tests/it/main.rs @@ -0,0 +1,6 @@ +mod common; +mod conformance; +mod headers; +mod perf; +mod pps_validation; +mod robustness; diff --git a/crates/h264/tests/perf.rs b/crates/h264/tests/it/perf.rs similarity index 91% rename from crates/h264/tests/perf.rs rename to crates/h264/tests/it/perf.rs index c8ed4beb8..da744c24b 100644 --- a/crates/h264/tests/perf.rs +++ b/crates/h264/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode speed on a 1080p High-profile stream (ignored by default; run with -//! `cargo test --release -p filmcraft-h264 --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-h264 --test it perf:: -- --ignored --nocapture`). -mod common; +use crate::common; use filmcraft_h264::Decoder; use std::time::Instant; diff --git a/crates/h264/tests/pps_validation.rs b/crates/h264/tests/it/pps_validation.rs similarity index 100% rename from crates/h264/tests/pps_validation.rs rename to crates/h264/tests/it/pps_validation.rs diff --git a/crates/h264/tests/robustness.rs b/crates/h264/tests/it/robustness.rs similarity index 99% rename from crates/h264/tests/robustness.rs rename to crates/h264/tests/it/robustness.rs index 902ab191d..898d50bef 100644 --- a/crates/h264/tests/robustness.rs +++ b/crates/h264/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! avcC / length-prefixed input and resilience against damaged streams. -mod common; +use crate::common; use filmcraft_bitstream::annexb_nals; use filmcraft_h264::Decoder; diff --git a/crates/h264enc/examples/h264enc_synth.rs b/crates/h264enc/examples/h264enc_synth.rs index 15ab55fd9..3d1b25f4c 100644 --- a/crates/h264enc/examples/h264enc_synth.rs +++ b/crates/h264enc/examples/h264enc_synth.rs @@ -2,7 +2,7 @@ //! //! `cargo run --release -p filmcraft-h264enc --example h264enc_synth -- W H FRAMES [profile] [preset] [bframes] [rate] [slices] [src]` //! profile: baseline|main|high, preset: speed|balanced|quality, rate: qpN | crfN | cbrN | vbrN -#[path = "../tests/common/mod.rs"] +#[path = "../tests/it/common/mod.rs"] mod common; use filmcraft_h264enc::*; diff --git a/crates/h264enc/tests/common/mod.rs b/crates/h264enc/tests/it/common/mod.rs similarity index 100% rename from crates/h264enc/tests/common/mod.rs rename to crates/h264enc/tests/it/common/mod.rs diff --git a/crates/h264enc/tests/crf_level_and_note.rs b/crates/h264enc/tests/it/crf_level_and_note.rs similarity index 98% rename from crates/h264enc/tests/crf_level_and_note.rs rename to crates/h264enc/tests/it/crf_level_and_note.rs index b93ebf66b..58306c073 100644 --- a/crates/h264enc/tests/crf_level_and_note.rs +++ b/crates/h264enc/tests/it/crf_level_and_note.rs @@ -1,7 +1,7 @@ //! Constant quality has no bitrate cap, so the level must allow what low factors spend; and every //! stream says which settings made it. -mod common; +use crate::common; use common::synth; use filmcraft_h264enc::*; diff --git a/crates/h264enc/tests/ffmpeg_oracle.rs b/crates/h264enc/tests/it/ffmpeg_oracle.rs similarity index 99% rename from crates/h264enc/tests/ffmpeg_oracle.rs rename to crates/h264enc/tests/it/ffmpeg_oracle.rs index e19cfc49b..409bf4102 100644 --- a/crates/h264enc/tests/ffmpeg_oracle.rs +++ b/crates/h264enc/tests/it/ffmpeg_oracle.rs @@ -3,7 +3,7 @@ //! Every stream must decode with zero errors, with the exact frame count, and the decoder's output must be //! bit-identical to the encoder's own reconstruction (catches prediction/transform/deblocking mismatches). -mod common; +use crate::common; use common::{Yuv, annexb_nals, ffmpeg, ffmpeg_decode, ffmpeg_source, ffprobe, out_dir, psnr, synth}; use filmcraft_h264enc::*; diff --git a/crates/h264enc/tests/it/main.rs b/crates/h264enc/tests/it/main.rs new file mode 100644 index 000000000..f673a96da --- /dev/null +++ b/crates/h264enc/tests/it/main.rs @@ -0,0 +1,3 @@ +mod common; +mod crf_level_and_note; +mod ffmpeg_oracle; diff --git a/crates/hevc/README.md b/crates/hevc/README.md index 43c266eda..59c762aed 100644 --- a/crates/hevc/README.md +++ b/crates/hevc/README.md @@ -96,7 +96,7 @@ with ffmpeg; tests print a message and skip when ffmpeg is absent). ffmpeg. - Robustness: hvcC input with pts round trip; randomly corrupted, truncated and garbage input never panics or deadlocks (single- and multi-threaded). -- Conformance (`tests/conformance.rs`): every fixture is decoded with 1 thread, 3 threads and every +- Conformance (`tests/it/conformance.rs`): every fixture is decoded with 1 thread, 3 threads and every core and compared sample-exactly with `ffmpeg -f rawvideo -pix_fmt yuv420p / yuv420p10le`; on mismatch the first differing frame, plane, sample, CTB and 8x8 block are reported. Output pts/POC order is checked too, and that no picture is flagged draft without draft mode. `compare_detects_mismatch` @@ -135,12 +135,12 @@ libx265 unless noted (x265 enables WPP by default, so most streams carry entry p | vt_main / vt_main10 | 640x360 | Apple VideoToolbox (hevc_videotoolbox), macOS only | | synthetic (7 streams) | 96x64 | tiles, PCM, slices, dependent segments, LTR, list modification | -`cargo test -p filmcraft-hevc --test conformance coverage_report -- --ignored --nocapture` prints the +`cargo test -p filmcraft-hevc --test it conformance::coverage_report -- --ignored --nocapture` prints the decoder statistics per fixture. ## Performance -`cargo test --release -p filmcraft-hevc --test perf -- --ignored --nocapture` (bit-exactness is +`cargo test --release -p filmcraft-hevc --test it perf:: -- --ignored --nocapture` (bit-exactness is verified first). Apple M4 Pro (14 cores), measured while the machine was heavily shared (load average 50-100), so these are lower bounds: diff --git a/crates/hevc/tests/fuzz_hostile.rs b/crates/hevc/tests/fuzz_hostile.rs index ac6829315..c74400674 100644 --- a/crates/hevc/tests/fuzz_hostile.rs +++ b/crates/hevc/tests/fuzz_hostile.rs @@ -1,6 +1,7 @@ //! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and //! random mutation of small libx265 streams must never panic or run away. Skips without ffmpeg. +#[path = "it/common/mod.rs"] mod common; use std::panic::{AssertUnwindSafe, catch_unwind}; @@ -83,6 +84,7 @@ fn run_all(d: &[u8], label: &str) { /// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). const DEADLINE_SECS: u64 = 600; +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=400` runs all of it (see [`Depth`]). #[test] fn hostile_streams_never_panic() { filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); @@ -90,6 +92,7 @@ fn hostile_streams_never_panic() { fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("hevc fuzz") else { return }; + let depth = Depth::from_env(); for &(name, enc, args, pix) in SEEDS { if !filmcraft_testkit::ffmpeg_has_encoder(&ff, enc) { filmcraft_testkit::skip("hevc fuzz", &format!("this ffmpeg has no {enc} encoder (seed {name})")); @@ -98,25 +101,31 @@ fn sweep() { 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) { + for n in depth.truncations(seed.len()) { run_all(&seed[..n], &format!("{name} truncate {n}")); } // SPS/PPS/first slice header region: every byte to a few values, every bit for p in 0..seed.len().min(120) { - for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } let mut d = seed.clone(); d[p] = v; run_all(&d, &format!("{name} byte {p}={v:#x}")); } for b in 0..8 { + if !depth.bit(p, b) { + continue; + } let mut d = seed.clone(); d[p] ^= 1 << b; run_all(&d, &format!("{name} flip {p}.{b}")); } } let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); - for i in 0..iters() { + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { let mut d = seed.clone(); for _ in 0..1 + rng.below(6) { let p = rng.below(d.len()); @@ -126,12 +135,61 @@ fn sweep() { d[p] = rng.next() as u8; } } - run_all(&d, &format!("{name} random {i}")); + if depth.round(i) { + 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) +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 300 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 400 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 300, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=400 runs everything)"); + Depth { full: false, truncations: 300, rounds: 400, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) } diff --git a/crates/hevc/tests/common/mod.rs b/crates/hevc/tests/it/common/mod.rs similarity index 99% rename from crates/hevc/tests/common/mod.rs rename to crates/hevc/tests/it/common/mod.rs index a8dcb8da2..61c1033f9 100644 --- a/crates/hevc/tests/common/mod.rs +++ b/crates/hevc/tests/it/common/mod.rs @@ -73,7 +73,7 @@ pub const FIXTURES: &[Fixture] = &[ fx!("odd_1918x1078", "testsrc2", 1918, 1078, 3, NOISE, 8, ["-preset", "veryfast", "-x265-params", "log-level=error"]), fx!("hd_1080p", "testsrc2", 1920, 1080, 3, "", 8, ["-preset", "fast", "-x265-params", "log-level=error"]), fx!("uhd_4k_main10", "testsrc2", 3840, 2160, 2, "", 10, ["-preset", "ultrafast", "-x265-params", "log-level=error"]), - // performance reference streams (tests/perf.rs) + // performance reference streams (tests/it/perf.rs) fx!("bench_1080p", "testsrc2", 1920, 1080, 60, "noise=alls=3:allf=t", 8, ["-preset", "medium", "-crf", "24", "-x265-params", "log-level=error"]), fx!("bench_4k", "testsrc2", 3840, 2160, 20, "noise=alls=3:allf=t", 10, ["-preset", "fast", "-crf", "26", "-x265-params", "log-level=error"]), // Apple VideoToolbox (hardware encoder, macOS only) diff --git a/crates/hevc/tests/conformance.rs b/crates/hevc/tests/it/conformance.rs similarity index 99% rename from crates/hevc/tests/conformance.rs rename to crates/hevc/tests/it/conformance.rs index 4fa8b8cbe..7b50cbaf0 100644 --- a/crates/hevc/tests/conformance.rs +++ b/crates/hevc/tests/it/conformance.rs @@ -1,7 +1,7 @@ //! Bit-exactness against ffmpeg's decoder on libx265 / VideoToolbox fixtures (skipped when ffmpeg is //! absent). -mod common; +use crate::common; macro_rules! fixture_tests { ($($name:ident),* $(,)?) => { diff --git a/crates/hevc/tests/it/main.rs b/crates/hevc/tests/it/main.rs new file mode 100644 index 000000000..be033f189 --- /dev/null +++ b/crates/hevc/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod conformance; +mod perf; +mod robustness; diff --git a/crates/hevc/tests/perf.rs b/crates/hevc/tests/it/perf.rs similarity index 91% rename from crates/hevc/tests/perf.rs rename to crates/hevc/tests/it/perf.rs index 4091166b6..ab8144a12 100644 --- a/crates/hevc/tests/perf.rs +++ b/crates/hevc/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode speed (ignored by default; run with -//! `cargo test --release -p filmcraft-hevc --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-hevc --test it perf:: -- --ignored --nocapture`). -mod common; +use crate::common; use filmcraft_hevc::Decoder; use std::time::Instant; diff --git a/crates/hevc/tests/robustness.rs b/crates/hevc/tests/it/robustness.rs similarity index 99% rename from crates/hevc/tests/robustness.rs rename to crates/hevc/tests/it/robustness.rs index 075d5fde2..50f35b0d4 100644 --- a/crates/hevc/tests/robustness.rs +++ b/crates/hevc/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! hvcC / length-prefixed input and resilience against damaged streams. -mod common; +use crate::common; use filmcraft_bitstream::annexb_nals; use filmcraft_hevc::Decoder; diff --git a/crates/interchange/README.md b/crates/interchange/README.md index edaff851f..b0cd33577 100644 --- a/crates/interchange/README.md +++ b/crates/interchange/README.md @@ -25,7 +25,7 @@ read off files it exports, never from the application itself. The other formats' editions are listed in the module docs (`edl`, `fcp7`, `fcpxml`, `otio`, `ale`). **Invariant — Unicode media URLs:** file URL prefix probes must not panic when their byte boundary -falls inside a UTF-8 character. Timeline importers use the shared converter; `tests/file_urls.rs` +falls inside a UTF-8 character. Timeline importers use the shared converter; `tests/it/file_urls.rs` checks expected Unicode paths through the public converter, resolver and EDL importer. ## AAF and OMF @@ -85,7 +85,7 @@ OMF carries no video at all. ## Tests -- `tests/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps, +- `tests/it/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps, two-sided and one-sided transitions, links, markers, start timecodes, constant and keyframed gain, media markers and timecode); 512-byte sectors; breakout to mono; embedded trimmed audio (sample data and source offsets); separate consolidated files with a video mixdown; OMF round @@ -93,7 +93,7 @@ OMF carries no video at all. random corruption never panic. Nested sequences: AAF round trips as compositions (nests in nests, a nest used twice, another frame and sample rate, breakout to mono), OMF with the rendered sound, the audio needs of nests, a sequence inside itself. -- `tests/uncarried_clips.rs`: every format's export report names each graphic clip and adjustment +- `tests/it/uncarried_clips.rs`: every format's export report names each graphic clip and adjustment layer once, as a warning; a sequence without them reports nothing; what a re-import gives back matches the report; the FCP7 XML import names a clip item whose file is not defined. - `src/aaf/tests.rs`: written files checked against the required properties of every class diff --git a/crates/interchange/tests/aaf_omf.rs b/crates/interchange/tests/it/aaf_omf.rs similarity index 99% rename from crates/interchange/tests/aaf_omf.rs rename to crates/interchange/tests/it/aaf_omf.rs index 3e16492f2..4a065677c 100644 --- a/crates/interchange/tests/aaf_omf.rs +++ b/crates/interchange/tests/it/aaf_omf.rs @@ -1,6 +1,6 @@ //! AAF and OMF: export → import round trips (no external oracle exists for these formats). -mod support; +use crate::support; use filmcraft_interchange::aaf::{self, AafOptions}; use filmcraft_interchange::essence::{AudioEssence, EssenceData, EssenceKey, MediaOptions, MixdownVideo, NeedOptions, NestNeeds, audio_needs}; diff --git a/crates/interchange/tests/api.rs b/crates/interchange/tests/it/api.rs similarity index 99% rename from crates/interchange/tests/api.rs rename to crates/interchange/tests/it/api.rs index 6a212793c..7d19eb3a9 100644 --- a/crates/interchange/tests/api.rs +++ b/crates/interchange/tests/it/api.rs @@ -1,6 +1,6 @@ //! Crate API: detection, merging fragments into a project, timecode rebasing. -mod support; +use crate::support; use std::collections::HashSet; diff --git a/crates/interchange/tests/edl.rs b/crates/interchange/tests/it/edl.rs similarity index 99% rename from crates/interchange/tests/edl.rs rename to crates/interchange/tests/it/edl.rs index 14100d146..db7369589 100644 --- a/crates/interchange/tests/edl.rs +++ b/crates/interchange/tests/it/edl.rs @@ -1,6 +1,6 @@ //! CMX 3600 EDL: golden documents (hand-written from the public CMX 3600 conventions) and round trips. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, ImportOptions, ReelMode, detect, export, export_edl_per_track, import, import_with}; use filmcraft_project::{ItemKind, MediaRef, TrackKind, TransitionAlign}; diff --git a/crates/interchange/tests/fcp7.rs b/crates/interchange/tests/it/fcp7.rs similarity index 99% rename from crates/interchange/tests/fcp7.rs rename to crates/interchange/tests/it/fcp7.rs index aad1d5f0e..663ce7337 100644 --- a/crates/interchange/tests/fcp7.rs +++ b/crates/interchange/tests/it/fcp7.rs @@ -1,7 +1,7 @@ //! Final Cut Pro 7 XML (xmeml): a hand-written Premiere-style document (written from the public //! xmeml element descriptions) and export → import round trips. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, Imported, detect, export, import}; use filmcraft_media::Generator; diff --git a/crates/interchange/tests/fcpxml.rs b/crates/interchange/tests/it/fcpxml.rs similarity index 99% rename from crates/interchange/tests/fcpxml.rs rename to crates/interchange/tests/it/fcpxml.rs index 740852a7a..2bf6f3c46 100644 --- a/crates/interchange/tests/fcpxml.rs +++ b/crates/interchange/tests/it/fcpxml.rs @@ -1,7 +1,7 @@ //! FCPXML: a hand-written document (from the public FCPXML element reference) with connected //! clips, a transition, a retimed clip, a gap and a compound clip; plus export → import round trips. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, FcpxmlVersion, Format, detect, import}; use filmcraft_project::{ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind, TransitionAlign}; diff --git a/crates/interchange/tests/file_urls.rs b/crates/interchange/tests/it/file_urls.rs similarity index 100% rename from crates/interchange/tests/file_urls.rs rename to crates/interchange/tests/it/file_urls.rs diff --git a/crates/interchange/tests/it/main.rs b/crates/interchange/tests/it/main.rs new file mode 100644 index 000000000..81da996e9 --- /dev/null +++ b/crates/interchange/tests/it/main.rs @@ -0,0 +1,12 @@ +mod aaf_omf; +mod api; +mod edl; +mod fcp7; +mod fcpxml; +mod file_urls; +mod media_marker_export; +mod nested_sequence_markers; +mod otio; +mod point_markers; +mod support; +mod uncarried_clips; diff --git a/crates/interchange/tests/media_marker_export.rs b/crates/interchange/tests/it/media_marker_export.rs similarity index 99% rename from crates/interchange/tests/media_marker_export.rs rename to crates/interchange/tests/it/media_marker_export.rs index 752cc8b5d..e209b0693 100644 --- a/crates/interchange/tests/media_marker_export.rs +++ b/crates/interchange/tests/it/media_marker_export.rs @@ -1,6 +1,6 @@ //! Source-media (master clip) markers must survive FCP7 XML export for xmeml 4 and 5. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, export, import}; use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; diff --git a/crates/interchange/tests/nested_sequence_markers.rs b/crates/interchange/tests/it/nested_sequence_markers.rs similarity index 99% rename from crates/interchange/tests/nested_sequence_markers.rs rename to crates/interchange/tests/it/nested_sequence_markers.rs index 9f69b755e..6e4eca92b 100644 --- a/crates/interchange/tests/nested_sequence_markers.rs +++ b/crates/interchange/tests/it/nested_sequence_markers.rs @@ -4,7 +4,7 @@ //! sequence's markers when the using clip has none, and must not put clip instance markers on the //! nested sequence. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format}; use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; diff --git a/crates/interchange/tests/otio.rs b/crates/interchange/tests/it/otio.rs similarity index 99% rename from crates/interchange/tests/otio.rs rename to crates/interchange/tests/it/otio.rs index 0b952add4..857de1e79 100644 --- a/crates/interchange/tests/otio.rs +++ b/crates/interchange/tests/it/otio.rs @@ -1,7 +1,7 @@ //! OpenTimelineIO: a hand-written plain OTIO document (from the public OTIO schema docs) with gaps, //! transitions, markers and legacy `Clip.1`; lossless round trips through `metadata.filmcraft`. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, detect, import}; use filmcraft_media::Generator; diff --git a/crates/interchange/tests/point_markers.rs b/crates/interchange/tests/it/point_markers.rs similarity index 99% rename from crates/interchange/tests/point_markers.rs rename to crates/interchange/tests/it/point_markers.rs index e0ffb5616..ba96cec58 100644 --- a/crates/interchange/tests/point_markers.rs +++ b/crates/interchange/tests/it/point_markers.rs @@ -1,6 +1,6 @@ //! FCPXML point markers: a one-frame `` is a FilmCraft point (duration 0), not a range. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, import}; use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, Project, TrackKind}; diff --git a/crates/interchange/tests/support/mod.rs b/crates/interchange/tests/it/support/mod.rs similarity index 100% rename from crates/interchange/tests/support/mod.rs rename to crates/interchange/tests/it/support/mod.rs diff --git a/crates/interchange/tests/uncarried_clips.rs b/crates/interchange/tests/it/uncarried_clips.rs similarity index 99% rename from crates/interchange/tests/uncarried_clips.rs rename to crates/interchange/tests/it/uncarried_clips.rs index 2f0902e26..136802897 100644 --- a/crates/interchange/tests/uncarried_clips.rs +++ b/crates/interchange/tests/it/uncarried_clips.rs @@ -2,7 +2,7 @@ //! export used to lose them without a word (FCP7 XML, OTIO, EDL) or with a line that named //! nothing (FCPXML, AAF): every such clip is now named in the export report. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, Report, export, import}; use filmcraft_project::{ItemId, ItemKind, Label, ParamValue, Project, TrackKind, find_effect}; diff --git a/crates/isobmff/README.md b/crates/isobmff/README.md index 47c31bb43..24cea2e43 100644 --- a/crates/isobmff/README.md +++ b/crates/isobmff/README.md @@ -118,18 +118,18 @@ let bytes = w.finish_faststart()?.into_inner(); // or finish() for moov-at-end ## Tests - Unit tests cover the box reader and hand-built `stbl` edge cases (stz2, co64, ctts v1, empty edits, 64-bit and size-0 boxes, uuid/wide/skip), codec config round-trips, and allocation guards. -- Property tests (`tests/roundtrip.rs`) write random multi-track files, both progressive (MP4/MOV, +- Property tests (`tests/it/roundtrip.rs`) write random multi-track files, both progressive (MP4/MOV, faststart, PCM, edits) and fragmented, then read them back and compare sample tables and bytes. -- ffprobe oracle tests (`tests/oracle_demux.rs`) run on ffmpeg-generated fixtures in +- ffprobe oracle tests (`tests/it/oracle_demux.rs`) run on ffmpeg-generated fixtures in `target/fixtures/isobmff/`: H.264 with B-frames (MP4 and MOV), HEVC, ProRes 422/4444, MJPEG, DNxHR, PCM s16le/s16be/s24le/f32le, AAC, ALAC, FLAC, Opus, AC-3, VP9, AV1, fragmented (with and without default-base-is-moof), faststart, an edit-list cut, `tmcd` at 29.97 DF and 23.976, and colr/pasp. They compare packet offsets, sizes, pts/dts, durations and key flags, plus stream parameters. Tests skip with a message when ffmpeg or an encoder is missing. -- Muxer oracle tests (`tests/oracle_mux.rs`) remux fixtures with our writers. They check that +- Muxer oracle tests (`tests/it/oracle_mux.rs`) remux fixtures with our writers. They check that `ffmpeg -v error -f null` reports nothing, that ffprobe packets and stream parameters match the source, and that timecode, the AAC priming edit, ipcm and fMP4 output are correct. -- Mutation fuzzing (`tests/fuzz.rs`) uses a deterministic seed to mutate and truncate synthetic and +- Mutation fuzzing (`tests/it/fuzz.rs`) uses a deterministic seed to mutate and truncate synthetic and ffmpeg files, and checks that nothing panics. ## Limitations / not yet supported diff --git a/crates/isobmff/tests/common/mod.rs b/crates/isobmff/tests/it/common/mod.rs similarity index 100% rename from crates/isobmff/tests/common/mod.rs rename to crates/isobmff/tests/it/common/mod.rs diff --git a/crates/isobmff/tests/fuzz.rs b/crates/isobmff/tests/it/fuzz.rs similarity index 99% rename from crates/isobmff/tests/fuzz.rs rename to crates/isobmff/tests/it/fuzz.rs index b6d2fcea6..17c376a5e 100644 --- a/crates/isobmff/tests/fuzz.rs +++ b/crates/isobmff/tests/it/fuzz.rs @@ -1,6 +1,6 @@ //! Deterministic mutation fuzzing: the demuxer must never panic on corrupted input. -mod common; +use crate::common; use filmcraft_isobmff::*; use std::io::Cursor; diff --git a/crates/isobmff/tests/it/main.rs b/crates/isobmff/tests/it/main.rs new file mode 100644 index 000000000..48e623e19 --- /dev/null +++ b/crates/isobmff/tests/it/main.rs @@ -0,0 +1,5 @@ +mod common; +mod fuzz; +mod oracle_demux; +mod oracle_mux; +mod roundtrip; diff --git a/crates/isobmff/tests/oracle_demux.rs b/crates/isobmff/tests/it/oracle_demux.rs similarity index 99% rename from crates/isobmff/tests/oracle_demux.rs rename to crates/isobmff/tests/it/oracle_demux.rs index 45ffe3b58..9e2cd0b28 100644 --- a/crates/isobmff/tests/oracle_demux.rs +++ b/crates/isobmff/tests/it/oracle_demux.rs @@ -1,6 +1,6 @@ //! Oracle tests: compare our demuxer's sample tables and codec parameters with ffprobe. -mod common; +use crate::common; use common::*; use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind, open}; diff --git a/crates/isobmff/tests/oracle_mux.rs b/crates/isobmff/tests/it/oracle_mux.rs similarity index 99% rename from crates/isobmff/tests/oracle_mux.rs rename to crates/isobmff/tests/it/oracle_mux.rs index 4b9144241..5383211f6 100644 --- a/crates/isobmff/tests/oracle_mux.rs +++ b/crates/isobmff/tests/it/oracle_mux.rs @@ -1,6 +1,6 @@ //! Muxer oracle tests: remux ffmpeg-made files with our writers and validate with ffmpeg/ffprobe. -mod common; +use crate::common; use common::*; use filmcraft_isobmff::*; diff --git a/crates/isobmff/tests/roundtrip.rs b/crates/isobmff/tests/it/roundtrip.rs similarity index 100% rename from crates/isobmff/tests/roundtrip.rs rename to crates/isobmff/tests/it/roundtrip.rs diff --git a/crates/media/tests/exr_oracle.rs b/crates/media/tests/it/exr_oracle.rs similarity index 100% rename from crates/media/tests/exr_oracle.rs rename to crates/media/tests/it/exr_oracle.rs diff --git a/crates/media/tests/hdr_oracle.rs b/crates/media/tests/it/hdr_oracle.rs similarity index 100% rename from crates/media/tests/hdr_oracle.rs rename to crates/media/tests/it/hdr_oracle.rs diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs new file mode 100644 index 000000000..43b1a5bcb --- /dev/null +++ b/crates/media/tests/it/main.rs @@ -0,0 +1,3 @@ +mod exr_oracle; +mod hdr_oracle; +mod tiff_ycbcr; diff --git a/crates/media/tests/tiff_ycbcr.rs b/crates/media/tests/it/tiff_ycbcr.rs similarity index 100% rename from crates/media/tests/tiff_ycbcr.rs rename to crates/media/tests/it/tiff_ycbcr.rs diff --git a/crates/mpeg2v/README.md b/crates/mpeg2v/README.md index 90d6d758d..2ccc86feb 100644 --- a/crates/mpeg2v/README.md +++ b/crates/mpeg2v/README.md @@ -80,7 +80,7 @@ are compared within a tolerance: **per frame max |Δ| ≤ 4 and PSNR ≥ 58 dB, samples differing**. Frame count, display order, picture types, interlacing and field order are exact (ffprobe). -`tests/oracle.rs` (FFmpeg-encoded elementary streams): +`tests/it/oracle.rs` (FFmpeg-encoded elementary streams): | Fixture | Content | Max Δ | Differing | PSNR | |---|---|---|---|---| @@ -113,7 +113,7 @@ FFmpeg's integer IDCT saturates differently from the exact one. ## Performance -`cargo test --release -p filmcraft-mpeg2v --test perf -- --ignored --nocapture` decodes the +`cargo test --release -p filmcraft-mpeg2v --test it perf:: -- --ignored --nocapture` decodes the 1080i fixture (1920×1080 4:2:0, 25 Mb/s, IBBP, field MC/DCT; FFmpeg-encoded) on an M4 Pro (14 cores) that was heavily loaded by other jobs (load average ≈ 190), best of five runs: **271 fps on one thread, 274 fps with slice rows on rayon** (runs under heavier momentary load @@ -129,5 +129,5 @@ fell to 77–150 fps). These are lower bounds. rounding, field addressing, edge clamping. - `src/tests.rs`: a hand-built intra picture decodes to its DC; truncation at every byte and 3000 random mutations never panic. -- `src/synth_tests.rs`, `tests/oracle.rs`: see above (single-threaded decode identical to the +- `src/synth_tests.rs`, `tests/it/oracle.rs`: see above (single-threaded decode identical to the threaded one). diff --git a/crates/mpeg2v/src/synth_tests.rs b/crates/mpeg2v/src/synth_tests.rs index 0d4dd5453..33baf11a8 100644 --- a/crates/mpeg2v/src/synth_tests.rs +++ b/crates/mpeg2v/src/synth_tests.rs @@ -725,7 +725,7 @@ fn check(cfg: Cfg) { } let psnr = 10.0 * (255f64 * 255.0 * n as f64 / sse.max(1e-9)).log10(); println!("{}: {} frames, max diff {worst}, {:.3}% differ, PSNR {psnr:.2} dB", cfg.name, pics.len(), 100.0 * differ as f64 / n as f64); - // the same criteria as the ffmpeg-encoded fixtures (tests/oracle.rs) + // the same criteria as the ffmpeg-encoded fixtures (tests/it/oracle.rs) assert!(worst <= 4 && psnr >= 58.0, "{} (seed {:#x}): max diff {worst}, PSNR {psnr:.2}, first at {first_bad:?}", cfg.name, cfg.seed); } diff --git a/crates/mpeg2v/src/tests.rs b/crates/mpeg2v/src/tests.rs index ca5f2e5e5..eeae62aae 100644 --- a/crates/mpeg2v/src/tests.rs +++ b/crates/mpeg2v/src/tests.rs @@ -1,5 +1,5 @@ //! Unit tests over hand-built streams (headers, robustness). Decoding accuracy against ffmpeg is -//! in `tests/oracle.rs`. +//! in `tests/it/oracle.rs`. use super::*; diff --git a/crates/mpeg2v/tests/fuzz_hostile.rs b/crates/mpeg2v/tests/fuzz_hostile.rs index d293af2d2..5d3aa21a2 100644 --- a/crates/mpeg2v/tests/fuzz_hostile.rs +++ b/crates/mpeg2v/tests/fuzz_hostile.rs @@ -2,6 +2,7 @@ //! sweeps over a small ffmpeg-generated stream must never panic or run away (errors are fine). //! Skips when ffmpeg is missing. +#[path = "it/common/mod.rs"] mod common; use std::panic::{AssertUnwindSafe, catch_unwind}; @@ -82,6 +83,7 @@ fn run_all(d: &[u8], label: &str) { /// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). const DEADLINE_SECS: u64 = 600; +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=2500` runs all of it (see [`Depth`]). #[test] fn hostile_streams_never_panic() { filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); @@ -89,6 +91,7 @@ fn hostile_streams_never_panic() { fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpeg2v fuzz") else { return }; + let depth = Depth::from_env(); for spec in &SPECS { if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "mpeg2video") { filmcraft_testkit::skip("mpeg2v fuzz", &format!("this ffmpeg has no mpeg2video encoder (seed {})", spec.name)); @@ -97,24 +100,29 @@ fn sweep() { 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()); + fuzz(&depth, spec.name, &std::fs::read(path).unwrap()); } } -fn fuzz(name: &str, seed: &[u8]) { +fn fuzz(depth: &Depth, 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) { + for n in depth.truncations(seed.len()) { run_all(&seed[..n], &format!("{name} truncate {n}")); } // header region: every byte to 0 / 0xff / 0x80 / each single bit for p in 0..seed.len().min(96) { - for v in [0u8, 0xff, 0x80, 0x01] { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } let mut d = seed.to_vec(); d[p] = v; run_all(&d, &format!("{name} byte {p}={v:#x}")); } for b in 0..8 { + if !depth.bit(p, b) { + continue; + } let mut d = seed.to_vec(); d[p] ^= 1 << b; run_all(&d, &format!("{name} flip {p}.{b}")); @@ -122,7 +130,8 @@ fn fuzz(name: &str, seed: &[u8]) { } // random multi-mutations let mut rng = Rng(0x9e3779b97f4a7c15); - for i in 0..iters() { + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { let mut d = seed.to_vec(); for _ in 0..1 + rng.below(6) { let p = rng.below(d.len()); @@ -132,11 +141,60 @@ fn fuzz(name: &str, seed: &[u8]) { d[p] = rng.next() as u8; } } - run_all(&d, &format!("{name} random {i}")); + if depth.round(i) { + 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) +const VALUES: [u8; 4] = [0, 0xff, 0x80, 0x01]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 600 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 2500 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 600, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=2500 runs everything)"); + Depth { full: false, truncations: 600, rounds: 2500, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) } diff --git a/crates/mpeg2v/tests/common/mod.rs b/crates/mpeg2v/tests/it/common/mod.rs similarity index 100% rename from crates/mpeg2v/tests/common/mod.rs rename to crates/mpeg2v/tests/it/common/mod.rs diff --git a/crates/mpeg2v/tests/hostile_dims.rs b/crates/mpeg2v/tests/it/hostile_dims.rs similarity index 100% rename from crates/mpeg2v/tests/hostile_dims.rs rename to crates/mpeg2v/tests/it/hostile_dims.rs diff --git a/crates/mpeg2v/tests/it/main.rs b/crates/mpeg2v/tests/it/main.rs new file mode 100644 index 000000000..bf473ddbb --- /dev/null +++ b/crates/mpeg2v/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod hostile_dims; +mod oracle; +mod perf; diff --git a/crates/mpeg2v/tests/oracle.rs b/crates/mpeg2v/tests/it/oracle.rs similarity index 99% rename from crates/mpeg2v/tests/oracle.rs rename to crates/mpeg2v/tests/it/oracle.rs index eed298c7d..00473c955 100644 --- a/crates/mpeg2v/tests/oracle.rs +++ b/crates/mpeg2v/tests/it/oracle.rs @@ -3,7 +3,7 @@ //! definition (any IEEE 1180-accurate IDCT conforms), so frames are compared within a tolerance; //! frame count and display order are exact. -mod common; +use crate::common; use common::*; diff --git a/crates/mpeg2v/tests/perf.rs b/crates/mpeg2v/tests/it/perf.rs similarity index 87% rename from crates/mpeg2v/tests/perf.rs rename to crates/mpeg2v/tests/it/perf.rs index 44281499c..10aaa1d6d 100644 --- a/crates/mpeg2v/tests/perf.rs +++ b/crates/mpeg2v/tests/it/perf.rs @@ -1,7 +1,7 @@ -//! Decode speed (ignored; run with `cargo test --release -p filmcraft-mpeg2v --test perf -- -//! --ignored --nocapture`). Uses the 1080i fixture of `tests/oracle.rs` (ffmpeg-encoded, 25 Mb/s). +//! Decode speed (ignored; run with `cargo test --release -p filmcraft-mpeg2v --test it perf:: -- +//! --ignored --nocapture`). Uses the 1080i fixture of `tests/it/oracle.rs` (ffmpeg-encoded, 25 Mb/s). -mod common; +use crate::common; use common::*; diff --git a/crates/mpegts/README.md b/crates/mpegts/README.md index 15bb9101e..a5543992c 100644 --- a/crates/mpegts/README.md +++ b/crates/mpegts/README.md @@ -65,7 +65,7 @@ let au: Vec = file.read_unit(&bytes, v, 0)?; // the first access unit 33-bit unwrap; truncation every 37 bytes and 400 random mutations never panic. - `src/es.rs`, `src/pes.rs`: frame sizes (MPEG audio layers I-III, MPEG-2 LSF, AC-3 at all rates), MPEG-2 and MPEG-1 PES headers. -- `crates/codecs/tests/mpeg_oracle.rs`: FFmpeg-written TS / M2TS / VOB / MPEG-1 system files — +- `crates/codecs/tests/it/mpeg_oracle.rs`: FFmpeg-written TS / M2TS / VOB / MPEG-1 system files — frame counts and every frame's PTS equal ffprobe's, decoded media against FFmpeg (see `filmcraft-codecs`). diff --git a/crates/mxf/README.md b/crates/mxf/README.md index 043772477..070045b98 100644 --- a/crates/mxf/README.md +++ b/crates/mxf/README.md @@ -129,7 +129,7 @@ w.finish()?; - `src/tests.rs`: a small OP1a writer builds synthetic files (temporal offsets, key flags, PCM, timecode); open, presentation order, sample bytes, PCM values; truncation at every 37 bytes and 600 random mutations never panic and only list fully-present samples. -- `crates/codecs/tests/mxf_oracle.rs` (FFmpeg-written fixtures, FFmpeg as the decode oracle): +- `crates/codecs/tests/it/mxf_oracle.rs` (FFmpeg-written fixtures, FFmpeg as the decode oracle): H.264 long-GOP with B pictures (bit-exact every frame + 25 random seeks), H.264 29.97 DF timecode, DNxHR LB (±2), ProRes 422 (±1), MPEG-2 long GOP, XDCAM HD422 and D-10 (±4), OP-Atom VC-3 (clip-wrapped) and PCM, D-10 AES3 audio; PCM sample-exact in every file; diff --git a/crates/ogg/README.md b/crates/ogg/README.md index a3d7f9196..851b9863e 100644 --- a/crates/ogg/README.md +++ b/crates/ogg/README.md @@ -37,6 +37,6 @@ let packet = ogg.read_packet(&bytes, s, k)?; ## Tests `src/tests.rs` writes synthetic pages: timing with pre-skip and end trimming, packets across pages, -a corrupted page (CRC), every truncation and 300 mutations. `crates/codecs/tests/ogg_oracle.rs` +a corrupted page (CRC), every truncation and 300 mutations. `crates/codecs/tests/it/ogg_oracle.rs` compares with FFmpeg's libopus / Vorbis decodes (exact length; SNR ≥ 50 dB CELT, ≥ 10 dB SILK, Vorbis 139 dB) and checks random seeks against the continuous decode. diff --git a/crates/opus/tests/common/mod.rs b/crates/opus/tests/it/common/mod.rs similarity index 86% rename from crates/opus/tests/common/mod.rs rename to crates/opus/tests/it/common/mod.rs index 6c6ec2754..01cc76605 100644 --- a/crates/opus/tests/common/mod.rs +++ b/crates/opus/tests/it/common/mod.rs @@ -32,12 +32,15 @@ fn spectra(x: &[f32], channels: usize, ch: usize, rate: u32) -> Vec<[f64; NFREQ] let w: Vec = (0..win).map(|i| 0.5 - 0.5 * (2.0 * std::f64::consts::PI * (i as f64 + 0.5) / win as f64).cos()).collect(); let nb = NFREQ / scale; let mut out = Vec::with_capacity(nframes); + let tw = twiddles(win); let mut buf = vec![(0f64, 0f64); win]; + let mut spec = vec![(0f64, 0f64); win]; + let mut scratch = vec![(0f64, 0f64); win]; for f in 0..nframes { for i in 0..win { buf[i] = (x[(f * step + i) * channels + ch] as f64 * w[i], 0.0); } - let spec = fft(&buf); + fft(&buf, 1, &mut spec, &tw, &mut scratch); let mut ps = [0f64; NFREQ]; for (k, p) in ps.iter_mut().enumerate().take(nb) { *p = spec[k].0 * spec[k].0 + spec[k].1 * spec[k].1; @@ -47,28 +50,49 @@ fn spectra(x: &[f32], channels: usize, ch: usize, rate: u32) -> Vec<[f64; NFREQ] out } -/// Recursive mixed-radix DFT (sizes with factors 2, 3, 5). -fn fft(x: &[(f64, f64)]) -> Vec<(f64, f64)> { - let n = x.len(); +/// `e^(-2πi·j/n)` for `j` in `0..n`: the twiddle factors of every sub-transform of a size-`n` FFT. +fn twiddles(n: usize) -> Vec<(f64, f64)> { + (0..n) + .map(|j| { + let ang = -2.0 * std::f64::consts::PI * j as f64 / n as f64; + (ang.cos(), ang.sin()) + }) + .collect() +} + +/// Recursive mixed-radix DFT (sizes with factors 2, 3, 5) of `x[0], x[xs], x[2·xs], …` into +/// `out`. `tw` is `twiddles` of the top-level size, which `out.len()` divides; `scratch` holds at +/// least `out.len()` values. +fn fft(x: &[(f64, f64)], xs: usize, out: &mut [(f64, f64)], tw: &[(f64, f64)], scratch: &mut [(f64, f64)]) { + let n = out.len(); if n == 1 { - return x.to_vec(); + out[0] = x[0]; + return; } let p = [2usize, 3, 5].into_iter().find(|p| n.is_multiple_of(*p)).unwrap_or(n); let m = n / p; - let subs: Vec> = (0..p).map(|r| fft(&x.iter().skip(r).step_by(p).copied().collect::>())).collect(); - let mut out = vec![(0f64, 0f64); n]; - for (k, o) in out.iter_mut().enumerate() { + let stride = tw.len() / n; + for r in 0..p { + fft(&x[r * xs..], xs * p, &mut out[r * m..(r + 1) * m], tw, scratch); + } + for (k, o) in scratch[..n].iter_mut().enumerate() { + let j = k % m; let mut acc = (0f64, 0f64); - for (r, sub) in subs.iter().enumerate() { - let v = sub[k % m]; - let ang = -2.0 * std::f64::consts::PI * (r * k) as f64 / n as f64; - let (c, s) = (ang.cos(), ang.sin()); + // e = r·k mod n, kept below n without a division per term. + let mut e = 0; + for r in 0..p { + let v = out[r * m + j]; + let (c, s) = tw[e * stride]; acc.0 += v.0 * c - v.1 * s; acc.1 += v.0 * s + v.1 * c; + e += k; + if e >= n { + e -= n; + } } *o = acc; } - out + out.copy_from_slice(&scratch[..n]); } /// Opus quality metric in the spirit of `opus_compare` (RFC 6716 §6.1): a per-band, diff --git a/crates/opus/tests/it/main.rs b/crates/opus/tests/it/main.rs new file mode 100644 index 000000000..48dc2080a --- /dev/null +++ b/crates/opus/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod oracle; +mod robustness; +mod vectors; diff --git a/crates/opus/tests/oracle.rs b/crates/opus/tests/it/oracle.rs similarity index 99% rename from crates/opus/tests/oracle.rs rename to crates/opus/tests/it/oracle.rs index 0917479ed..f80236737 100644 --- a/crates/opus/tests/oracle.rs +++ b/crates/opus/tests/it/oracle.rs @@ -7,7 +7,7 @@ //! streams containing SILK differ by the (non-normative) SILK resampler, so they are held to a //! lower SNR bound and the spectral quality metric. -mod common; +use crate::common; use std::process::Command; diff --git a/crates/opus/tests/robustness.rs b/crates/opus/tests/it/robustness.rs similarity index 100% rename from crates/opus/tests/robustness.rs rename to crates/opus/tests/it/robustness.rs diff --git a/crates/opus/tests/vectors.rs b/crates/opus/tests/it/vectors.rs similarity index 99% rename from crates/opus/tests/vectors.rs rename to crates/opus/tests/it/vectors.rs index e36529b7c..7b36fbe7a 100644 --- a/crates/opus/tests/vectors.rs +++ b/crates/opus/tests/it/vectors.rs @@ -7,7 +7,7 @@ //! `.dec` output using our implementation of the opus_compare-style quality metric //! (see `common::opus_quality`), for stereo (`.dec`) and mono (`m.dec`) output at 48 kHz. -mod common; +use crate::common; use std::path::PathBuf; diff --git a/crates/platform/README.md b/crates/platform/README.md index b78c57735..00371ba97 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -289,18 +289,18 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | test | what | |---|---| -| `tests/videotoolbox.rs` (macOS) | H.264 High, HEVC Main (open GOP: CRA + RASL) and HEVC Main 10, 640×360 (coded 368: cropping) with B-frames: every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample, mid-stream `flush`, and a full pass after resets; forced mid-stream failures (`VtDecoder::fail_after`) at five points continue with the software decoder's exact output; seeded mutation of samples and parameter sets (bit flips, truncation, corrupt length prefixes) never panics or hangs; HEVC 4:2:2 10-bit is bit-exact with ffmpeg's decode | -| `tests/fallback.rs` (every OS) | `HybridDecoder` with a stand-in hardware decoder failing after N samples (every sync sample ± a few, first / last sample, after a seek): output identical to the software decoder; in-band parameter sets identical to the sample entry's stay in hardware, different ones switch to software | +| `tests/it/videotoolbox.rs` (macOS) | H.264 High, HEVC Main (open GOP: CRA + RASL) and HEVC Main 10, 640×360 (coded 368: cropping) with B-frames: every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample, mid-stream `flush`, and a full pass after resets; forced mid-stream failures (`VtDecoder::fail_after`) at five points continue with the software decoder's exact output; seeded mutation of samples and parameter sets (bit flips, truncation, corrupt length prefixes) never panics or hangs; HEVC 4:2:2 10-bit is bit-exact with ffmpeg's decode | +| `tests/it/fallback.rs` (every OS) | `HybridDecoder` with a stand-in hardware decoder failing after N samples (every sync sample ± a few, first / last sample, after a seek): output identical to the software decoder; in-band parameter sets identical to the sample entry's stay in hardware, different ones switch to software | | `tests/setting.rs` | Hardware decoding Off gives the software decoder through `make_video_decoder` and the media stack (no hardware frames); Auto gives VideoToolbox where available | -| `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | -| `tests/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | -| `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | -| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | -| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | -| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | -| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | +| `tests/it/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | +| `tests/it/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | +| `tests/it/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | +| `tests/it/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | +| `tests/it/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | +| `tests/it/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | +| `tests/it/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | | `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | -| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | +| `tests/it/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | | `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity | | `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | | `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages | diff --git a/crates/platform/tests/common/mod.rs b/crates/platform/tests/it/common/mod.rs similarity index 96% rename from crates/platform/tests/common/mod.rs rename to crates/platform/tests/it/common/mod.rs index e4f39c59e..74f11bdae 100644 --- a/crates/platform/tests/common/mod.rs +++ b/crates/platform/tests/it/common/mod.rs @@ -9,6 +9,10 @@ use filmcraft_codecs::{DecodedFrame, VideoDecoder}; use filmcraft_frame::PixelData; use filmcraft_isobmff::SampleEntry; +/// NVENC sessions are a limited resource on consumer GPUs, and the encoder counters are +/// process-wide: one NVENC test at a time across every module of the binary. +pub static NVENC_SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// The parity fixtures: (file, ffmpeg arguments). B-frames everywhere (reordering), a 360-line /// picture (coded 368: cropping), two GOPs or more (seeks), and an open-GOP HEVC stream (CRA with /// RASL pictures). diff --git a/crates/platform/tests/fallback.rs b/crates/platform/tests/it/fallback.rs similarity index 84% rename from crates/platform/tests/fallback.rs rename to crates/platform/tests/it/fallback.rs index 8c00e52f5..3b7da8d5c 100644 --- a/crates/platform/tests/fallback.rs +++ b/crates/platform/tests/it/fallback.rs @@ -5,7 +5,7 @@ //! damaged sample that the hardware decoded but the software decoder rejects, inside the replayed //! run, loses no picture. -mod common; +use crate::common; use common::*; use filmcraft_codecs::hw::NalStreamInfo; @@ -51,30 +51,31 @@ fn failures_anywhere_continue_with_the_software_output() { for (name, _) in FIXTURES { let Some(path) = named(&ff, name) else { continue }; let s = read_stream(&path); - let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples); let syncs: Vec = (0..s.samples.len()).filter(|&i| s.sync[i]).collect(); - let mut points: Vec = vec![0, 1, 3, 7, s.samples.len() - 1, s.samples.len()]; - for &k in &syncs { - points.extend([k, k + 1, k + 2, k + 5]); - } + let [_, k, .., last] = syncs[..] else { panic!("{name}: three GOPs or more") }; + // The stream up to a few samples into its last GOP: past the last failure point the + // hybrid only passes the software decoder's output on. + let samples = &s.samples[..(last + 6).min(s.samples.len())]; + let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), samples); + // The hybrid's paths differ by what the replay log holds and what the hardware had + // pending: in the first GOP (nothing out yet, then pictures held for reordering), at and + // just after the second restart point (the log starts over, or an HEVC CRA keeps the GOP + // before it), at the last one (a CRA drops the GOP before the previous one), at the last + // sample and never. Failing anywhere else takes one of those paths. + let points = [0, 1, 3, 7, k, k + 1, k + 2, k + 5, last, last + 2, samples.len() - 1, samples.len()]; for fail_after in points { let mut d = hybrid(&s, fail_after); - let out = decode_all(&mut d, &s.samples); - assert_eq!(d.is_hardware(), fail_after >= s.samples.len(), "{name}: fell back at {fail_after}"); + let out = decode_all(&mut d, samples); + assert_eq!(d.is_hardware(), fail_after >= samples.len(), "{name}: fell back at {fail_after}"); assert_same(&format!("{name} failing after {fail_after} samples"), &out, &reference); } // a failure after a seek (reset) into the second GOP - if let Some(&k) = syncs.get(1) { - let tail = &s.samples[k..]; - let expect = { - let mut sw = filmcraft_codecs::software_video_decoder(&s.entry).unwrap(); - decode_all(sw.as_mut(), tail) - }; - let mut d = hybrid(&s, k + 4); - decode_all(&mut d, &s.samples[..k]); - d.reset(); - assert_same(&format!("{name} failing after a seek"), &decode_all(&mut d, tail), &expect); - } + let tail = &samples[k..]; + let expect = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), tail); + let mut d = hybrid(&s, k + 4); + decode_all(&mut d, &samples[..k]); + d.reset(); + assert_same(&format!("{name} failing after a seek"), &decode_all(&mut d, tail), &expect); } } @@ -179,7 +180,9 @@ fn a_sample_software_rejects_during_the_replay_loses_no_pictures() { // the second restart point, and a disposable picture before it that is not the first sample let Some(next) = (1..s.samples.len()).find(|&i| s.sync[i]) else { continue }; let Some(d) = (2..next.saturating_sub(2)).find(|&i| info.is_disposable(&s.samples[i].0)) else { continue }; - let mut stream = s.samples.clone(); + // the first two GOPs: the replay and the restart after it + let end = (next + 1..s.samples.len()).find(|&i| s.sync[i]).unwrap_or(s.samples.len()); + let mut stream = s.samples[..end].to_vec(); stream[d].0 = damaged(&s.samples[d].0, header); let (reference, rejected) = decode_lenient(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &stream); assert_eq!(rejected, [d], "{name}: the software decoder rejects the damaged sample (and only it)"); diff --git a/crates/platform/tests/hardware_encode.rs b/crates/platform/tests/it/hardware_encode.rs similarity index 100% rename from crates/platform/tests/hardware_encode.rs rename to crates/platform/tests/it/hardware_encode.rs diff --git a/crates/platform/tests/hevc_probe.rs b/crates/platform/tests/it/hevc_probe.rs similarity index 100% rename from crates/platform/tests/hevc_probe.rs rename to crates/platform/tests/it/hevc_probe.rs diff --git a/crates/platform/tests/it/main.rs b/crates/platform/tests/it/main.rs new file mode 100644 index 000000000..b863c7d04 --- /dev/null +++ b/crates/platform/tests/it/main.rs @@ -0,0 +1,13 @@ +mod common; +mod fallback; +mod hardware_encode; +mod hevc_probe; +mod media_foundation; +mod nvenc; +mod nvenc_export; +mod nvenc_hevc; +mod nvenc_hevc_export; +mod nvenc_hevc_main10; +mod nvenc_hevc_probe; +mod nvenc_rgba_input; +mod videotoolbox; diff --git a/crates/platform/tests/media_foundation.rs b/crates/platform/tests/it/media_foundation.rs similarity index 99% rename from crates/platform/tests/media_foundation.rs rename to crates/platform/tests/it/media_foundation.rs index 31c53a7b7..5b7a09d7f 100644 --- a/crates/platform/tests/media_foundation.rs +++ b/crates/platform/tests/it/media_foundation.rs @@ -6,7 +6,7 @@ //! hang. Skips without ffmpeg (fixture generator) or without a hardware decoder. #![cfg(target_os = "windows")] -mod common; +use crate::common; use std::time::Duration; diff --git a/crates/platform/tests/nvenc.rs b/crates/platform/tests/it/nvenc.rs similarity index 100% rename from crates/platform/tests/nvenc.rs rename to crates/platform/tests/it/nvenc.rs diff --git a/crates/platform/tests/nvenc_export.rs b/crates/platform/tests/it/nvenc_export.rs similarity index 95% rename from crates/platform/tests/nvenc_export.rs rename to crates/platform/tests/it/nvenc_export.rs index e0d32a20c..d02bcca35 100644 --- a/crates/platform/tests/nvenc_export.rs +++ b/crates/platform/tests/it/nvenc_export.rs @@ -13,9 +13,6 @@ use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceS use filmcraft_render::SourceMap; use filmcraft_time::{FrameRate, Tick, TimeRange}; -/// The encoder counters are process-wide: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn project(w: u32, h: u32, frames: i64) -> (Arc, filmcraft_project::ItemId, SourceMap) { let rate = FrameRate::FPS_24; let mut p = Project::new("x"); @@ -79,7 +76,7 @@ fn settings(path: String, hw: HardwareEncoding) -> ExportSettings { #[test] fn hardware_export_matches_the_software_export() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); let (p, seq, m) = project(1280, 720, 72); let soft_path = tmp("soft.mp4"); @@ -117,7 +114,7 @@ fn hardware_export_matches_the_software_export() { #[test] fn what_nvenc_does_not_take_goes_to_the_software_encoder() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); let (p, seq, m) = project(640, 360, 24); let run = |name: &str, f: &dyn Fn(&mut ExportSettings)| { diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/it/nvenc_hevc.rs similarity index 95% rename from crates/platform/tests/nvenc_hevc.rs rename to crates/platform/tests/it/nvenc_hevc.rs index acd2bee9b..f0aebcc45 100644 --- a/crates/platform/tests/nvenc_hevc.rs +++ b/crates/platform/tests/it/nvenc_hevc.rs @@ -43,9 +43,6 @@ fn config(w: u32, h: u32) -> Config { } } -/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - type Sources = Vec<(Vec, Vec, Vec)>; /// `None` (the test skips) only on a machine without an HEVC encoder; on one that has it, a @@ -94,7 +91,7 @@ fn hex(b: &[u8]) -> String { #[test] fn the_stream_decodes_to_the_source() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let (w, h) = (1280u32, 720u32); let Some((enc, packets, sources)) = encode_all(&config(w, h), 72) else { return }; assert_eq!(enc.codec(), Codec::Hevc); @@ -179,7 +176,7 @@ fn the_stream_decodes_to_the_source() { #[test] fn nothing_to_encode_with_is_an_error_not_a_crash() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); return; @@ -222,7 +219,7 @@ fn calm(what: &str, f: impl FnOnce() -> T) -> T { #[test] fn hostile_configurations_give_errors_not_crashes() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); return; @@ -266,7 +263,7 @@ fn hostile_configurations_give_errors_not_crashes() { #[test] fn an_encoder_dropped_mid_stream_releases_everything() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); return; @@ -285,7 +282,7 @@ fn an_encoder_dropped_mid_stream_releases_everything() { #[test] fn high_bitrates_move_up_a_level_and_stay_in_the_main_tier() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // 1080p30 at a 12 / 18 Mbit/s peak: above level 4's Main tier (12), where NVENC's own choice was // level 4 High tier, which many hardware decoders refuse for (w, h, fps, kbps, max_kbps, level_idc) in @@ -302,7 +299,7 @@ fn high_bitrates_move_up_a_level_and_stay_in_the_main_tier() { #[test] fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // NVENC wants a GOP longer than the B-frame pattern: short GOPs drop the B-frames, they do not fail for keyint in [1u32, 2] { let cfg = Config { keyint, ..config(640, 360) }; @@ -324,7 +321,7 @@ fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() { #[test] fn vui_timing_matches_integer_and_fractional_frame_rates() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); for fps in [(24, 1), (25, 1), (30_000, 1_001)] { let mut cfg = config(640, 360); cfg.fps = fps; diff --git a/crates/platform/tests/nvenc_hevc_export.rs b/crates/platform/tests/it/nvenc_hevc_export.rs similarity index 97% rename from crates/platform/tests/nvenc_hevc_export.rs rename to crates/platform/tests/it/nvenc_hevc_export.rs index 95f074bb8..5fb79a9f7 100644 --- a/crates/platform/tests/nvenc_hevc_export.rs +++ b/crates/platform/tests/it/nvenc_hevc_export.rs @@ -21,9 +21,6 @@ use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceS use filmcraft_render::SourceMap; use filmcraft_time::{FrameRate, Tick, TimeRange}; -/// The encoder counters are process-wide: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn project(w: u32, h: u32, frames: i64) -> (Arc, filmcraft_project::ItemId, SourceMap) { let rate = FrameRate::FPS_24; let mut p = Project::new("x"); @@ -115,7 +112,7 @@ fn delta(before: filmcraft_export::HwEncodeStats) -> (u64, u64, u64) { #[test] fn hevc_exports_play_everywhere() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); assert_eq!(filmcraft_export::available(Format::Hevc), hevc_available(), "the format is available iff NVENC can encode HEVC"); if !hevc_available() { @@ -173,7 +170,7 @@ fn hevc_exports_play_everywhere() { #[test] fn hevc_export_with_audio() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); @@ -202,7 +199,7 @@ fn hevc_export_with_audio() { #[test] fn sizes_that_are_not_multiples_of_the_coding_block_are_cropped_back() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); @@ -230,7 +227,7 @@ fn sizes_that_are_not_multiples_of_the_coding_block_are_cropped_back() { #[test] fn what_nvenc_cannot_do_is_an_error_that_says_why() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); let rate = FrameRate::FPS_24; let base = hevc(tmp("declined.mp4")); diff --git a/crates/platform/tests/nvenc_hevc_main10.rs b/crates/platform/tests/it/nvenc_hevc_main10.rs similarity index 96% rename from crates/platform/tests/nvenc_hevc_main10.rs rename to crates/platform/tests/it/nvenc_hevc_main10.rs index 76d0c9ccc..1b49795dd 100644 --- a/crates/platform/tests/nvenc_hevc_main10.rs +++ b/crates/platform/tests/it/nvenc_hevc_main10.rs @@ -16,9 +16,6 @@ const W: usize = 1280; const H: usize = 720; const FRAMES: usize = 48; -/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn config(w: u32, h: u32) -> Config { Config { width: w, @@ -134,7 +131,7 @@ fn hex(b: &[u8]) -> String { } fn round_trip(name: &str, signal: ColorSignal, want_sei: bool) { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((enc, packets, sources)) = encode_all(&config(W as u32, H as u32), &signal, FRAMES) else { return }; assert!(enc.is_ten_bit()); assert_eq!(packets.len(), FRAMES, "{name}: one packet per picture"); @@ -239,7 +236,7 @@ fn calm(what: &str, f: impl FnOnce() -> T) -> T { #[test] fn main_10_and_main_encoders_refuse_each_others_pictures() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_hdr_available() { eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder"); return; @@ -268,7 +265,7 @@ fn main_10_and_main_encoders_refuse_each_others_pictures() { #[test] fn hostile_main_10_configurations_give_errors_not_crashes() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_hdr_available() { eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder"); return; @@ -315,7 +312,7 @@ fn hostile_main_10_configurations_give_errors_not_crashes() { #[test] fn the_probe_follows_the_encoder() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let hdr = hevc_hdr_available(); assert!(!hdr || hevc_available(), "Main 10 implies HEVC"); assert_eq!(hevc_hdr_available(), hdr, "the answer is kept"); diff --git a/crates/platform/tests/nvenc_hevc_probe.rs b/crates/platform/tests/it/nvenc_hevc_probe.rs similarity index 100% rename from crates/platform/tests/nvenc_hevc_probe.rs rename to crates/platform/tests/it/nvenc_hevc_probe.rs diff --git a/crates/platform/tests/nvenc_rgba_input.rs b/crates/platform/tests/it/nvenc_rgba_input.rs similarity index 94% rename from crates/platform/tests/nvenc_rgba_input.rs rename to crates/platform/tests/it/nvenc_rgba_input.rs index 4009f3e68..275b1e227 100644 --- a/crates/platform/tests/nvenc_rgba_input.rs +++ b/crates/platform/tests/it/nvenc_rgba_input.rs @@ -6,9 +6,6 @@ use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available}; -/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - /// A moving test picture: gradient background, a moving box, a little texture. fn moving(w: usize, h: usize, i: usize) -> Vec { let mut v = vec![0u8; w * h * 4]; @@ -122,7 +119,7 @@ fn check(profile: Profile) { #[test] fn hevc_rgba_input_converts_like_the_export() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC encoder"); return; @@ -132,7 +129,7 @@ fn hevc_rgba_input_converts_like_the_export() { #[test] fn h264_rgba_input_converts_like_the_export() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !available() { eprintln!("SKIPPED: no NVENC"); return; @@ -142,7 +139,7 @@ fn h264_rgba_input_converts_like_the_export() { #[test] fn main10_has_no_rgba_input_and_short_pictures_are_errors() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC encoder"); return; diff --git a/crates/platform/tests/videotoolbox.rs b/crates/platform/tests/it/videotoolbox.rs similarity index 99% rename from crates/platform/tests/videotoolbox.rs rename to crates/platform/tests/it/videotoolbox.rs index c2ce2f6ae..2db339b1b 100644 --- a/crates/platform/tests/videotoolbox.rs +++ b/crates/platform/tests/it/videotoolbox.rs @@ -6,7 +6,7 @@ //! hang. Skips without ffmpeg (fixture generator) or without a hardware decoder. #![cfg(target_os = "macos")] -mod common; +use crate::common; use std::time::Duration; diff --git a/crates/platform/tests/media_stack.rs b/crates/platform/tests/media_stack.rs index a7d244f5d..e8be399d8 100644 --- a/crates/platform/tests/media_stack.rs +++ b/crates/platform/tests/media_stack.rs @@ -2,6 +2,7 @@ //! decoding Auto and Off (Windows). Its own test binary: the switch is process-wide. #![cfg(target_os = "windows")] +#[path = "it/common/mod.rs"] mod common; use common::*; diff --git a/crates/platform/tests/nvdec.rs b/crates/platform/tests/nvdec.rs index d87f15611..ef1a689dc 100644 --- a/crates/platform/tests/nvdec.rs +++ b/crates/platform/tests/nvdec.rs @@ -4,9 +4,11 @@ //! `reset` + reseek; a forced mid-stream failure must continue in software with the software //! decoder's exact output; what the hardware path does not take is declined; damaged samples give //! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without -//! an NVIDIA GPU. +//! an NVIDIA GPU. Its own test binary: one test turns the process-wide Hardware decoding switch +//! off and on. #![cfg(all(target_os = "linux", target_pointer_width = "64"))] +#[path = "it/common/mod.rs"] mod common; use std::time::Duration; diff --git a/crates/platform/tests/setting.rs b/crates/platform/tests/setting.rs index c58f4a953..259a02c31 100644 --- a/crates/platform/tests/setting.rs +++ b/crates/platform/tests/setting.rs @@ -2,6 +2,7 @@ //! Off gives the software decoder (and no hardware frames), Auto the hardware one where the //! system has it. Its own test binary: the switch is process-wide. +#[path = "it/common/mod.rs"] mod common; use common::*; diff --git a/crates/platform/tests/vaapi.rs b/crates/platform/tests/vaapi.rs index 507e7768c..f7c140661 100644 --- a/crates/platform/tests/vaapi.rs +++ b/crates/platform/tests/vaapi.rs @@ -8,9 +8,11 @@ //! continue in software with the software decoder's exact output; what the hardware path does not //! take (interlaced and 10-bit H.264, 4:2:2 HEVC) is declined; damaged samples and parameter sets give //! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without a -//! VA-API driver. +//! VA-API driver. Its own test binary: one test turns the process-wide Hardware decoding switch off +//! and on. #![cfg(target_os = "linux")] +#[path = "it/common/mod.rs"] mod common; use std::time::Duration; diff --git a/crates/platform/tests/vaapi_displays.rs b/crates/platform/tests/vaapi_displays.rs index 8ec45334a..acdb721d8 100644 --- a/crates/platform/tests/vaapi_displays.rs +++ b/crates/platform/tests/vaapi_displays.rs @@ -7,6 +7,7 @@ //! Skips without ffmpeg (fixture generator) or without a VA-API driver. #![cfg(target_os = "linux")] +#[path = "it/common/mod.rs"] mod common; use common::*; diff --git a/crates/prores/README.md b/crates/prores/README.md index 99336e2bc..3659d1a0f 100644 --- a/crates/prores/README.md +++ b/crates/prores/README.md @@ -58,7 +58,7 @@ does), so the decoder doesn't need to know. rayon task directly into the output planes. - Robustness: every size field is validated before allocation, codewords longer than 56 bits are rejected, and arithmetic is overflow-safe. Mutated streams return `Err`, never panic (see - `tests/fuzz.rs`). + `tests/it/fuzz.rs`). ## Encoder @@ -79,7 +79,7 @@ does), so the decoder doesn't need to know. ## Accuracy (vs ffmpeg as an external oracle) -`tests/oracle_decode.rs` decodes 29 fixtures made by ffmpeg's `prores_ks` and `prores_aw` +`tests/it/oracle_decode.rs` decodes 29 fixtures made by ffmpeg's `prores_ks` and `prores_aw` encoders. They cover every profile, testsrc2/mandelbrot/SMPTE bars at 1920×1080, 1918×1080, 1280×720, 640×360, 352×288, 70×38 and 333×201, interlaced 720×486 TFF/BFF and 1080i, 2-MB slices, and 4444 with 8- and 16-bit alpha. The results are compared with ffmpeg's decode at the native @@ -89,7 +89,7 @@ bit-identical (86% for 12-bit XQ mandelbrot). Alpha is always bit-exact. The rem differences are the reference decoder's integer-IDCT rounding. This decoder is closer to the exact IDCT and does not reproduce that approximation. -`tests/oracle_encode.rs` encodes 1920×1080 frames with every profile (testsrc2, mandelbrot and +`tests/it/oracle_encode.rs` encodes 1920×1080 frames with every profile (testsrc2, mandelbrot and noisy content; 4444 with an alpha disc) and writes them into a minimal test-only MOV. ffmpeg then decodes them with `-xerror`, with no errors, and agrees with this decoder within ±1. HQ PSNR is 69.9/69.2 dB (Y/C) on mandelbrot at 100% of the target rate. Busy content lands at 99.9–100.4% @@ -97,7 +97,7 @@ of the target. ## Performance -Measured with `cargo test --release -p filmcraft-prores --test perf -- --ignored --nocapture` on +Measured with `cargo test --release -p filmcraft-prores --test it perf:: -- --ignored --nocapture` on an M4 Pro (14 cores) that was heavily loaded by other jobs, so these are lower bounds. A 1080p HQ frame at the full nominal rate (918 KB) decoded at up to 318 fps threaded and 53 fps on one thread. A prores_ks HQ mandelbrot frame (657 KB) decoded at 390 fps threaded. Encoding a 1080p diff --git a/crates/prores/tests/common/mod.rs b/crates/prores/tests/it/common/mod.rs similarity index 100% rename from crates/prores/tests/common/mod.rs rename to crates/prores/tests/it/common/mod.rs diff --git a/crates/prores/tests/fuzz.rs b/crates/prores/tests/it/fuzz.rs similarity index 99% rename from crates/prores/tests/fuzz.rs rename to crates/prores/tests/it/fuzz.rs index cd012a04c..4b4292f8a 100644 --- a/crates/prores/tests/fuzz.rs +++ b/crates/prores/tests/it/fuzz.rs @@ -1,6 +1,6 @@ //! Robustness: mutated and random inputs must never panic (errors are fine). -mod common; +use crate::common; use filmcraft_prores::*; struct Rng(u64); diff --git a/crates/prores/tests/fuzz_hostile.rs b/crates/prores/tests/it/fuzz_hostile.rs similarity index 100% rename from crates/prores/tests/fuzz_hostile.rs rename to crates/prores/tests/it/fuzz_hostile.rs diff --git a/crates/prores/tests/it/main.rs b/crates/prores/tests/it/main.rs new file mode 100644 index 000000000..f6423aad1 --- /dev/null +++ b/crates/prores/tests/it/main.rs @@ -0,0 +1,7 @@ +mod common; +mod fuzz; +mod fuzz_hostile; +mod oracle_decode; +mod oracle_encode; +mod perf; +mod roundtrip; diff --git a/crates/prores/tests/oracle_decode.rs b/crates/prores/tests/it/oracle_decode.rs similarity index 99% rename from crates/prores/tests/oracle_decode.rs rename to crates/prores/tests/it/oracle_decode.rs index 1b93eda97..660aceb52 100644 --- a/crates/prores/tests/oracle_decode.rs +++ b/crates/prores/tests/it/oracle_decode.rs @@ -1,6 +1,6 @@ //! Decoder accuracy against ffmpeg's ProRes decoder (oracle only; skipped without ffmpeg). -mod common; +use crate::common; use common::*; use filmcraft_prores::{AlphaType, ChromaFormat, DecodeOptions, Interlace, decode_frame, decode_frame_with, probe}; diff --git a/crates/prores/tests/oracle_encode.rs b/crates/prores/tests/it/oracle_encode.rs similarity index 99% rename from crates/prores/tests/oracle_encode.rs rename to crates/prores/tests/it/oracle_encode.rs index ed269a6d3..5fd9adc9f 100644 --- a/crates/prores/tests/oracle_encode.rs +++ b/crates/prores/tests/it/oracle_encode.rs @@ -1,6 +1,6 @@ //! Encoder conformance and quality against ffmpeg's ProRes decoder (oracle only). -mod common; +use crate::common; use common::*; use filmcraft_prores::{ChromaFormat, DecodeOptions, Encoder, Frame, Profile, decode_frame, decode_frame_with}; diff --git a/crates/prores/tests/perf.rs b/crates/prores/tests/it/perf.rs similarity index 95% rename from crates/prores/tests/perf.rs rename to crates/prores/tests/it/perf.rs index a35e12828..1c9902b93 100644 --- a/crates/prores/tests/perf.rs +++ b/crates/prores/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode/encode throughput. Run with -//! `cargo test --release -p filmcraft-prores --test perf -- --ignored --nocapture`. +//! `cargo test --release -p filmcraft-prores --test it perf:: -- --ignored --nocapture`. -mod common; +use crate::common; use filmcraft_prores::*; use std::time::Instant; diff --git a/crates/prores/tests/roundtrip.rs b/crates/prores/tests/it/roundtrip.rs similarity index 99% rename from crates/prores/tests/roundtrip.rs rename to crates/prores/tests/it/roundtrip.rs index e3b058e0b..b6ced3ba5 100644 --- a/crates/prores/tests/roundtrip.rs +++ b/crates/prores/tests/it/roundtrip.rs @@ -1,6 +1,6 @@ //! Encoder → decoder round trips on synthetic content (no external tools needed). -mod common; +use crate::common; use common::psnr; use filmcraft_prores::*; diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs new file mode 100644 index 000000000..73e898571 --- /dev/null +++ b/crates/render/tests/it/main.rs @@ -0,0 +1,4 @@ +mod median_radius; +mod noise_frame_cadence; +mod sharpen_preview_res; +mod underline_direction; diff --git a/crates/render/tests/median_radius.rs b/crates/render/tests/it/median_radius.rs similarity index 100% rename from crates/render/tests/median_radius.rs rename to crates/render/tests/it/median_radius.rs diff --git a/crates/render/tests/noise_frame_cadence.rs b/crates/render/tests/it/noise_frame_cadence.rs similarity index 100% rename from crates/render/tests/noise_frame_cadence.rs rename to crates/render/tests/it/noise_frame_cadence.rs diff --git a/crates/render/tests/sharpen_preview_res.rs b/crates/render/tests/it/sharpen_preview_res.rs similarity index 100% rename from crates/render/tests/sharpen_preview_res.rs rename to crates/render/tests/it/sharpen_preview_res.rs diff --git a/crates/render/tests/underline_direction.rs b/crates/render/tests/it/underline_direction.rs similarity index 100% rename from crates/render/tests/underline_direction.rs rename to crates/render/tests/it/underline_direction.rs diff --git a/crates/ui-egui/tests/audio51_ui.rs b/crates/ui-egui/tests/it/audio51_ui.rs similarity index 100% rename from crates/ui-egui/tests/audio51_ui.rs rename to crates/ui-egui/tests/it/audio51_ui.rs diff --git a/crates/ui-egui/tests/audio_fx_editor_ui.rs b/crates/ui-egui/tests/it/audio_fx_editor_ui.rs similarity index 100% rename from crates/ui-egui/tests/audio_fx_editor_ui.rs rename to crates/ui-egui/tests/it/audio_fx_editor_ui.rs diff --git a/crates/ui-egui/tests/caption_properties_ui.rs b/crates/ui-egui/tests/it/caption_properties_ui.rs similarity index 100% rename from crates/ui-egui/tests/caption_properties_ui.rs rename to crates/ui-egui/tests/it/caption_properties_ui.rs diff --git a/crates/ui-egui/tests/captions_font_ui.rs b/crates/ui-egui/tests/it/captions_font_ui.rs similarity index 100% rename from crates/ui-egui/tests/captions_font_ui.rs rename to crates/ui-egui/tests/it/captions_font_ui.rs diff --git a/crates/ui-egui/tests/chrome_ui.rs b/crates/ui-egui/tests/it/chrome_ui.rs similarity index 100% rename from crates/ui-egui/tests/chrome_ui.rs rename to crates/ui-egui/tests/it/chrome_ui.rs diff --git a/crates/ui-egui/tests/clip_audio_ui.rs b/crates/ui-egui/tests/it/clip_audio_ui.rs similarity index 100% rename from crates/ui-egui/tests/clip_audio_ui.rs rename to crates/ui-egui/tests/it/clip_audio_ui.rs diff --git a/crates/ui-egui/tests/clip_dialogs_ui.rs b/crates/ui-egui/tests/it/clip_dialogs_ui.rs similarity index 100% rename from crates/ui-egui/tests/clip_dialogs_ui.rs rename to crates/ui-egui/tests/it/clip_dialogs_ui.rs diff --git a/crates/ui-egui/tests/clipboard_paste_ui.rs b/crates/ui-egui/tests/it/clipboard_paste_ui.rs similarity index 100% rename from crates/ui-egui/tests/clipboard_paste_ui.rs rename to crates/ui-egui/tests/it/clipboard_paste_ui.rs diff --git a/crates/ui-egui/tests/color_ui.rs b/crates/ui-egui/tests/it/color_ui.rs similarity index 100% rename from crates/ui-egui/tests/color_ui.rs rename to crates/ui-egui/tests/it/color_ui.rs diff --git a/crates/ui-egui/tests/delete_caption_tracks_ui.rs b/crates/ui-egui/tests/it/delete_caption_tracks_ui.rs similarity index 100% rename from crates/ui-egui/tests/delete_caption_tracks_ui.rs rename to crates/ui-egui/tests/it/delete_caption_tracks_ui.rs diff --git a/crates/ui-egui/tests/deselect_ui.rs b/crates/ui-egui/tests/it/deselect_ui.rs similarity index 100% rename from crates/ui-egui/tests/deselect_ui.rs rename to crates/ui-egui/tests/it/deselect_ui.rs diff --git a/crates/ui-egui/tests/dialog_buttons_ui.rs b/crates/ui-egui/tests/it/dialog_buttons_ui.rs similarity index 100% rename from crates/ui-egui/tests/dialog_buttons_ui.rs rename to crates/ui-egui/tests/it/dialog_buttons_ui.rs diff --git a/crates/ui-egui/tests/edit_point_menu_ui.rs b/crates/ui-egui/tests/it/edit_point_menu_ui.rs similarity index 100% rename from crates/ui-egui/tests/edit_point_menu_ui.rs rename to crates/ui-egui/tests/it/edit_point_menu_ui.rs diff --git a/crates/ui-egui/tests/escape_drag_ui.rs b/crates/ui-egui/tests/it/escape_drag_ui.rs similarity index 100% rename from crates/ui-egui/tests/escape_drag_ui.rs rename to crates/ui-egui/tests/it/escape_drag_ui.rs diff --git a/crates/ui-egui/tests/essential_sound_ui.rs b/crates/ui-egui/tests/it/essential_sound_ui.rs similarity index 100% rename from crates/ui-egui/tests/essential_sound_ui.rs rename to crates/ui-egui/tests/it/essential_sound_ui.rs diff --git a/crates/ui-egui/tests/export_menu_ui.rs b/crates/ui-egui/tests/it/export_menu_ui.rs similarity index 100% rename from crates/ui-egui/tests/export_menu_ui.rs rename to crates/ui-egui/tests/it/export_menu_ui.rs diff --git a/crates/ui-egui/tests/export_ui.rs b/crates/ui-egui/tests/it/export_ui.rs similarity index 100% rename from crates/ui-egui/tests/export_ui.rs rename to crates/ui-egui/tests/it/export_ui.rs diff --git a/crates/ui-egui/tests/frame_export_ui.rs b/crates/ui-egui/tests/it/frame_export_ui.rs similarity index 100% rename from crates/ui-egui/tests/frame_export_ui.rs rename to crates/ui-egui/tests/it/frame_export_ui.rs diff --git a/crates/ui-egui/tests/graphics_handles_ui.rs b/crates/ui-egui/tests/it/graphics_handles_ui.rs similarity index 100% rename from crates/ui-egui/tests/graphics_handles_ui.rs rename to crates/ui-egui/tests/it/graphics_handles_ui.rs diff --git a/crates/ui-egui/tests/graphics_notice_ui.rs b/crates/ui-egui/tests/it/graphics_notice_ui.rs similarity index 100% rename from crates/ui-egui/tests/graphics_notice_ui.rs rename to crates/ui-egui/tests/it/graphics_notice_ui.rs diff --git a/crates/ui-egui/tests/graphics_templates_ui.rs b/crates/ui-egui/tests/it/graphics_templates_ui.rs similarity index 100% rename from crates/ui-egui/tests/graphics_templates_ui.rs rename to crates/ui-egui/tests/it/graphics_templates_ui.rs diff --git a/crates/ui-egui/tests/graphics_ui.rs b/crates/ui-egui/tests/it/graphics_ui.rs similarity index 100% rename from crates/ui-egui/tests/graphics_ui.rs rename to crates/ui-egui/tests/it/graphics_ui.rs diff --git a/crates/ui-egui/tests/interchange_export_ui.rs b/crates/ui-egui/tests/it/interchange_export_ui.rs similarity index 100% rename from crates/ui-egui/tests/interchange_export_ui.rs rename to crates/ui-egui/tests/it/interchange_export_ui.rs diff --git a/crates/ui-egui/tests/interface_zoom_ui.rs b/crates/ui-egui/tests/it/interface_zoom_ui.rs similarity index 100% rename from crates/ui-egui/tests/interface_zoom_ui.rs rename to crates/ui-egui/tests/it/interface_zoom_ui.rs diff --git a/crates/ui-egui/tests/keyboard_ui.rs b/crates/ui-egui/tests/it/keyboard_ui.rs similarity index 100% rename from crates/ui-egui/tests/keyboard_ui.rs rename to crates/ui-egui/tests/it/keyboard_ui.rs diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/it/keyframes_ui.rs similarity index 100% rename from crates/ui-egui/tests/keyframes_ui.rs rename to crates/ui-egui/tests/it/keyframes_ui.rs diff --git a/crates/ui-egui/tests/links_ui.rs b/crates/ui-egui/tests/it/links_ui.rs similarity index 100% rename from crates/ui-egui/tests/links_ui.rs rename to crates/ui-egui/tests/it/links_ui.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 000000000..c5a3437d2 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,80 @@ +mod audio51_ui; +mod audio_fx_editor_ui; +mod caption_properties_ui; +mod captions_font_ui; +mod chrome_ui; +mod clip_audio_ui; +mod clip_dialogs_ui; +mod clipboard_paste_ui; +mod color_ui; +mod delete_caption_tracks_ui; +mod deselect_ui; +mod dialog_buttons_ui; +mod edit_point_menu_ui; +mod escape_drag_ui; +mod essential_sound_ui; +mod export_menu_ui; +mod export_ui; +mod frame_export_ui; +mod graphics_handles_ui; +mod graphics_notice_ui; +mod graphics_templates_ui; +mod graphics_ui; +mod interchange_export_ui; +mod interface_zoom_ui; +mod keyboard_ui; +mod keyframes_ui; +mod links_ui; +mod marked_playback_position; +mod mask_roundness_ui; +mod masks_ui; +mod media_ui; +mod menus_ui; +mod meters_ui; +mod mixer_ui; +mod monitor_geometry; +mod multicam_ui; +mod native_dialogs_ui; +mod nest_editing_ui; +mod nest_rendering_ui; +mod nested_ui; +mod panels_ui; +mod par_ui; +mod perf_ui; +mod playback_clock_ui; +mod project_panel_ui; +mod properties_scroll_ui; +mod remix_ui; +mod scripted; +mod sequence_tabs_ui; +mod sequence_ui; +mod settings_ui; +mod shared_docking_ui; +mod source_drag_ui; +mod source_playback_ui; +mod timecode_ui; +mod timeline_drop_ui; +mod timeline_escape_ui; +mod timeline_gap_ui; +mod timeline_marquee_ui; +mod timeline_middle_pan_ui; +mod timeline_option_drag_ui; +mod timeline_scroll_ui; +mod timeline_tabs_ui; +mod timeline_track_height_ui; +mod timeline_transition_move_ui; +mod timeline_trim_ui; +mod track_color_ui; +mod track_select_drag_ui; +mod transcript_ui; +mod transform_handles_ui; +mod transition_drop_ui; +mod transitions_ui; +mod tts_ui; +mod ui_control_input; +mod ui_dock_resize; +mod ui_keyboard_focus; +mod unlink_ui; +mod view_ui; +mod voiceover_ui; +mod workspaces_ui; diff --git a/crates/ui-egui/tests/marked_playback_position.rs b/crates/ui-egui/tests/it/marked_playback_position.rs similarity index 100% rename from crates/ui-egui/tests/marked_playback_position.rs rename to crates/ui-egui/tests/it/marked_playback_position.rs diff --git a/crates/ui-egui/tests/mask_roundness_ui.rs b/crates/ui-egui/tests/it/mask_roundness_ui.rs similarity index 100% rename from crates/ui-egui/tests/mask_roundness_ui.rs rename to crates/ui-egui/tests/it/mask_roundness_ui.rs diff --git a/crates/ui-egui/tests/masks_ui.rs b/crates/ui-egui/tests/it/masks_ui.rs similarity index 100% rename from crates/ui-egui/tests/masks_ui.rs rename to crates/ui-egui/tests/it/masks_ui.rs diff --git a/crates/ui-egui/tests/media_ui.rs b/crates/ui-egui/tests/it/media_ui.rs similarity index 100% rename from crates/ui-egui/tests/media_ui.rs rename to crates/ui-egui/tests/it/media_ui.rs diff --git a/crates/ui-egui/tests/menus_ui.rs b/crates/ui-egui/tests/it/menus_ui.rs similarity index 100% rename from crates/ui-egui/tests/menus_ui.rs rename to crates/ui-egui/tests/it/menus_ui.rs diff --git a/crates/ui-egui/tests/meters_ui.rs b/crates/ui-egui/tests/it/meters_ui.rs similarity index 100% rename from crates/ui-egui/tests/meters_ui.rs rename to crates/ui-egui/tests/it/meters_ui.rs diff --git a/crates/ui-egui/tests/mixer_ui.rs b/crates/ui-egui/tests/it/mixer_ui.rs similarity index 100% rename from crates/ui-egui/tests/mixer_ui.rs rename to crates/ui-egui/tests/it/mixer_ui.rs diff --git a/crates/ui-egui/tests/monitor_geometry.rs b/crates/ui-egui/tests/it/monitor_geometry.rs similarity index 100% rename from crates/ui-egui/tests/monitor_geometry.rs rename to crates/ui-egui/tests/it/monitor_geometry.rs diff --git a/crates/ui-egui/tests/multicam_ui.rs b/crates/ui-egui/tests/it/multicam_ui.rs similarity index 100% rename from crates/ui-egui/tests/multicam_ui.rs rename to crates/ui-egui/tests/it/multicam_ui.rs diff --git a/crates/ui-egui/tests/native_dialogs_ui.rs b/crates/ui-egui/tests/it/native_dialogs_ui.rs similarity index 100% rename from crates/ui-egui/tests/native_dialogs_ui.rs rename to crates/ui-egui/tests/it/native_dialogs_ui.rs diff --git a/crates/ui-egui/tests/nest_editing_ui.rs b/crates/ui-egui/tests/it/nest_editing_ui.rs similarity index 100% rename from crates/ui-egui/tests/nest_editing_ui.rs rename to crates/ui-egui/tests/it/nest_editing_ui.rs diff --git a/crates/ui-egui/tests/nest_rendering_ui.rs b/crates/ui-egui/tests/it/nest_rendering_ui.rs similarity index 100% rename from crates/ui-egui/tests/nest_rendering_ui.rs rename to crates/ui-egui/tests/it/nest_rendering_ui.rs diff --git a/crates/ui-egui/tests/nested_ui.rs b/crates/ui-egui/tests/it/nested_ui.rs similarity index 100% rename from crates/ui-egui/tests/nested_ui.rs rename to crates/ui-egui/tests/it/nested_ui.rs diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/it/panels_ui.rs similarity index 100% rename from crates/ui-egui/tests/panels_ui.rs rename to crates/ui-egui/tests/it/panels_ui.rs diff --git a/crates/ui-egui/tests/par_ui.rs b/crates/ui-egui/tests/it/par_ui.rs similarity index 100% rename from crates/ui-egui/tests/par_ui.rs rename to crates/ui-egui/tests/it/par_ui.rs diff --git a/crates/ui-egui/tests/perf_ui.rs b/crates/ui-egui/tests/it/perf_ui.rs similarity index 100% rename from crates/ui-egui/tests/perf_ui.rs rename to crates/ui-egui/tests/it/perf_ui.rs diff --git a/crates/ui-egui/tests/playback_clock_ui.rs b/crates/ui-egui/tests/it/playback_clock_ui.rs similarity index 100% rename from crates/ui-egui/tests/playback_clock_ui.rs rename to crates/ui-egui/tests/it/playback_clock_ui.rs diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/it/project_panel_ui.rs similarity index 100% rename from crates/ui-egui/tests/project_panel_ui.rs rename to crates/ui-egui/tests/it/project_panel_ui.rs diff --git a/crates/ui-egui/tests/properties_scroll_ui.rs b/crates/ui-egui/tests/it/properties_scroll_ui.rs similarity index 100% rename from crates/ui-egui/tests/properties_scroll_ui.rs rename to crates/ui-egui/tests/it/properties_scroll_ui.rs diff --git a/crates/ui-egui/tests/remix_ui.rs b/crates/ui-egui/tests/it/remix_ui.rs similarity index 100% rename from crates/ui-egui/tests/remix_ui.rs rename to crates/ui-egui/tests/it/remix_ui.rs diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/it/scripted.rs similarity index 99% rename from crates/ui-egui/tests/scripted.rs rename to crates/ui-egui/tests/it/scripted.rs index 0bb1dd741..0fc6fbcc0 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/it/scripted.rs @@ -401,7 +401,7 @@ impl Driver { } /// Renders the Trim Monitor and the Keyboard Shortcuts dialog to PNGs for visual review (needs a -/// GPU): `cargo test -p filmcraft-ui-egui --test scripted -- --ignored screenshots`. +/// GPU): `cargo test -p filmcraft-ui-egui --test it scripted::screenshots -- --ignored`. #[test] #[ignore] fn screenshots() { diff --git a/crates/ui-egui/tests/sequence_tabs_ui.rs b/crates/ui-egui/tests/it/sequence_tabs_ui.rs similarity index 100% rename from crates/ui-egui/tests/sequence_tabs_ui.rs rename to crates/ui-egui/tests/it/sequence_tabs_ui.rs diff --git a/crates/ui-egui/tests/sequence_ui.rs b/crates/ui-egui/tests/it/sequence_ui.rs similarity index 100% rename from crates/ui-egui/tests/sequence_ui.rs rename to crates/ui-egui/tests/it/sequence_ui.rs diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/it/settings_ui.rs similarity index 100% rename from crates/ui-egui/tests/settings_ui.rs rename to crates/ui-egui/tests/it/settings_ui.rs diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs similarity index 100% rename from crates/ui-egui/tests/shared_docking_ui.rs rename to crates/ui-egui/tests/it/shared_docking_ui.rs diff --git a/crates/ui-egui/tests/source_drag_ui.rs b/crates/ui-egui/tests/it/source_drag_ui.rs similarity index 100% rename from crates/ui-egui/tests/source_drag_ui.rs rename to crates/ui-egui/tests/it/source_drag_ui.rs diff --git a/crates/ui-egui/tests/source_playback_ui.rs b/crates/ui-egui/tests/it/source_playback_ui.rs similarity index 100% rename from crates/ui-egui/tests/source_playback_ui.rs rename to crates/ui-egui/tests/it/source_playback_ui.rs diff --git a/crates/ui-egui/tests/timecode_ui.rs b/crates/ui-egui/tests/it/timecode_ui.rs similarity index 100% rename from crates/ui-egui/tests/timecode_ui.rs rename to crates/ui-egui/tests/it/timecode_ui.rs diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/it/timeline_drop_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_drop_ui.rs rename to crates/ui-egui/tests/it/timeline_drop_ui.rs diff --git a/crates/ui-egui/tests/timeline_escape_ui.rs b/crates/ui-egui/tests/it/timeline_escape_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_escape_ui.rs rename to crates/ui-egui/tests/it/timeline_escape_ui.rs diff --git a/crates/ui-egui/tests/timeline_gap_ui.rs b/crates/ui-egui/tests/it/timeline_gap_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_gap_ui.rs rename to crates/ui-egui/tests/it/timeline_gap_ui.rs diff --git a/crates/ui-egui/tests/timeline_marquee_ui.rs b/crates/ui-egui/tests/it/timeline_marquee_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_marquee_ui.rs rename to crates/ui-egui/tests/it/timeline_marquee_ui.rs diff --git a/crates/ui-egui/tests/timeline_middle_pan_ui.rs b/crates/ui-egui/tests/it/timeline_middle_pan_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_middle_pan_ui.rs rename to crates/ui-egui/tests/it/timeline_middle_pan_ui.rs diff --git a/crates/ui-egui/tests/timeline_option_drag_ui.rs b/crates/ui-egui/tests/it/timeline_option_drag_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_option_drag_ui.rs rename to crates/ui-egui/tests/it/timeline_option_drag_ui.rs diff --git a/crates/ui-egui/tests/timeline_scroll_ui.rs b/crates/ui-egui/tests/it/timeline_scroll_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_scroll_ui.rs rename to crates/ui-egui/tests/it/timeline_scroll_ui.rs diff --git a/crates/ui-egui/tests/timeline_tabs_ui.rs b/crates/ui-egui/tests/it/timeline_tabs_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_tabs_ui.rs rename to crates/ui-egui/tests/it/timeline_tabs_ui.rs diff --git a/crates/ui-egui/tests/timeline_track_height_ui.rs b/crates/ui-egui/tests/it/timeline_track_height_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_track_height_ui.rs rename to crates/ui-egui/tests/it/timeline_track_height_ui.rs diff --git a/crates/ui-egui/tests/timeline_transition_move_ui.rs b/crates/ui-egui/tests/it/timeline_transition_move_ui.rs similarity index 100% rename from crates/ui-egui/tests/timeline_transition_move_ui.rs rename to crates/ui-egui/tests/it/timeline_transition_move_ui.rs diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/it/timeline_trim_ui.rs similarity index 99% rename from crates/ui-egui/tests/timeline_trim_ui.rs rename to crates/ui-egui/tests/it/timeline_trim_ui.rs index 94aabf400..d6243d548 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/it/timeline_trim_ui.rs @@ -256,7 +256,7 @@ fn cursor_hit_test_and_press_agree_at_a_cut() { } /// Renders the hover bracket at the City_Night_Drive / Desert_Dunes cut to PNGs for visual -/// review (needs a GPU): `cargo test -p filmcraft-ui-egui --test timeline_trim_ui -- --ignored +/// review (needs a GPU): `cargo test -p filmcraft-ui-egui --test it timeline_trim_ui:: -- --ignored /// hover_bracket_screenshots`. Yellow on City_Night_Drive's Out edge just left of the cut and on /// Desert_Dunes' In edge just right of it, red for the Ripple tool, both sides for the Rolling tool. #[test] @@ -301,7 +301,7 @@ fn dragging_a_caption_edge_inward_trims_it() { /// #374: renders the Ambient_Score music clip mid-way through a trim of its In edge, with the /// mouse button still down, for visual review (needs a GPU): `cargo test -p filmcraft-ui-egui -/// --test timeline_trim_ui -- --ignored trim_waveform_screenshots`. The waveform stays where it was +/// --test it timeline_trim_ui::trim_waveform_screenshots -- --ignored`. The waveform stays where it was /// and is cut off at the moving edge; it used to squeeze the whole clip's waveform into the shorter /// clip, so the sound under the pointer moved while dragging. #[test] diff --git a/crates/ui-egui/tests/track_color_ui.rs b/crates/ui-egui/tests/it/track_color_ui.rs similarity index 100% rename from crates/ui-egui/tests/track_color_ui.rs rename to crates/ui-egui/tests/it/track_color_ui.rs diff --git a/crates/ui-egui/tests/track_select_drag_ui.rs b/crates/ui-egui/tests/it/track_select_drag_ui.rs similarity index 100% rename from crates/ui-egui/tests/track_select_drag_ui.rs rename to crates/ui-egui/tests/it/track_select_drag_ui.rs diff --git a/crates/ui-egui/tests/transcript_ui.rs b/crates/ui-egui/tests/it/transcript_ui.rs similarity index 100% rename from crates/ui-egui/tests/transcript_ui.rs rename to crates/ui-egui/tests/it/transcript_ui.rs diff --git a/crates/ui-egui/tests/transform_handles_ui.rs b/crates/ui-egui/tests/it/transform_handles_ui.rs similarity index 100% rename from crates/ui-egui/tests/transform_handles_ui.rs rename to crates/ui-egui/tests/it/transform_handles_ui.rs diff --git a/crates/ui-egui/tests/transition_drop_ui.rs b/crates/ui-egui/tests/it/transition_drop_ui.rs similarity index 100% rename from crates/ui-egui/tests/transition_drop_ui.rs rename to crates/ui-egui/tests/it/transition_drop_ui.rs diff --git a/crates/ui-egui/tests/transitions_ui.rs b/crates/ui-egui/tests/it/transitions_ui.rs similarity index 100% rename from crates/ui-egui/tests/transitions_ui.rs rename to crates/ui-egui/tests/it/transitions_ui.rs diff --git a/crates/ui-egui/tests/tts_ui.rs b/crates/ui-egui/tests/it/tts_ui.rs similarity index 100% rename from crates/ui-egui/tests/tts_ui.rs rename to crates/ui-egui/tests/it/tts_ui.rs diff --git a/crates/ui-egui/tests/ui_control_input.rs b/crates/ui-egui/tests/it/ui_control_input.rs similarity index 100% rename from crates/ui-egui/tests/ui_control_input.rs rename to crates/ui-egui/tests/it/ui_control_input.rs diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/it/ui_dock_resize.rs similarity index 100% rename from crates/ui-egui/tests/ui_dock_resize.rs rename to crates/ui-egui/tests/it/ui_dock_resize.rs diff --git a/crates/ui-egui/tests/ui_keyboard_focus.rs b/crates/ui-egui/tests/it/ui_keyboard_focus.rs similarity index 100% rename from crates/ui-egui/tests/ui_keyboard_focus.rs rename to crates/ui-egui/tests/it/ui_keyboard_focus.rs diff --git a/crates/ui-egui/tests/unlink_ui.rs b/crates/ui-egui/tests/it/unlink_ui.rs similarity index 100% rename from crates/ui-egui/tests/unlink_ui.rs rename to crates/ui-egui/tests/it/unlink_ui.rs diff --git a/crates/ui-egui/tests/view_ui.rs b/crates/ui-egui/tests/it/view_ui.rs similarity index 100% rename from crates/ui-egui/tests/view_ui.rs rename to crates/ui-egui/tests/it/view_ui.rs diff --git a/crates/ui-egui/tests/voiceover_ui.rs b/crates/ui-egui/tests/it/voiceover_ui.rs similarity index 100% rename from crates/ui-egui/tests/voiceover_ui.rs rename to crates/ui-egui/tests/it/voiceover_ui.rs diff --git a/crates/ui-egui/tests/workspaces_ui.rs b/crates/ui-egui/tests/it/workspaces_ui.rs similarity index 100% rename from crates/ui-egui/tests/workspaces_ui.rs rename to crates/ui-egui/tests/it/workspaces_ui.rs diff --git a/crates/vp8/README.md b/crates/vp8/README.md index affb7ff34..5b855a21f 100644 --- a/crates/vp8/README.md +++ b/crates/vp8/README.md @@ -28,8 +28,8 @@ test oracles. Bit-exact with libvpx on the 62 libvpx VP8 test vectors checked (`vp80-00-comprehensive-001` to `018` and the `vp80-01` to `vp80-06` intra, inter, segmentation, partition, sharpness and small -size vectors; `tests/vectors.rs` checks them when they are present) and on the generated fixtures -of `tests/conformance.rs`. +size vectors; `tests/it/vectors.rs` checks them when they are present) and on the generated fixtures +of `tests/it/conformance.rs`. ## API diff --git a/crates/vp8/tests/common/mod.rs b/crates/vp8/tests/it/common/mod.rs similarity index 94% rename from crates/vp8/tests/common/mod.rs rename to crates/vp8/tests/it/common/mod.rs index cc42e8e88..15416b864 100644 --- a/crates/vp8/tests/common/mod.rs +++ b/crates/vp8/tests/it/common/mod.rs @@ -108,15 +108,17 @@ pub fn run(cmd: &mut Command) -> bool { /// Generate (if needed) the fixture stream and its reference decode by libvpx through ffmpeg. /// `None` (the test skips) without ffmpeg or its libvpx encoder. pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { - // one generation at a time: tests share fixtures, and realtime libvpx encodes differ from run - // to run, so a stream and its reference decode must come from the same encode - static GENERATING: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _guard = GENERATING.lock().unwrap_or_else(|e| e.into_inner()); let Some(ff) = ffmpeg() else { eprintln!("SKIP {}: ffmpeg not found", f.name); return None; }; let dir = fixtures_dir(); + // one generation at a time, across test processes too: tests share fixtures, and realtime + // libvpx encodes differ from run to run, so a stream and its reference decode must come from + // the same encode + // a lock failure is a broken environment, not a missing tool: fail rather than skip + let lock = std::fs::File::create(dir.join("vp8.generate.lock")).expect("create the fixture lock file"); + lock.lock().expect("lock the fixture lock file"); let ivf = dir.join(format!("{}.ivf", f.name)); let yuv = dir.join(format!("{}.yuv", f.name)); if !ivf.exists() { diff --git a/crates/vp8/tests/conformance.rs b/crates/vp8/tests/it/conformance.rs similarity index 99% rename from crates/vp8/tests/conformance.rs rename to crates/vp8/tests/it/conformance.rs index 27c7b92aa..3f120877f 100644 --- a/crates/vp8/tests/conformance.rs +++ b/crates/vp8/tests/it/conformance.rs @@ -1,7 +1,7 @@ //! Bit-exactness against libvpx (through ffmpeg) on generated fixtures covering every coding //! tool: skipped when ffmpeg or its libvpx encoder is absent. -mod common; +use crate::common; fn check(name: &str) { let f = common::fixture(name); diff --git a/crates/vp8/tests/it/main.rs b/crates/vp8/tests/it/main.rs new file mode 100644 index 000000000..ffc58b90c --- /dev/null +++ b/crates/vp8/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod conformance; +mod robustness; +mod vectors; diff --git a/crates/vp8/tests/robustness.rs b/crates/vp8/tests/it/robustness.rs similarity index 99% rename from crates/vp8/tests/robustness.rs rename to crates/vp8/tests/it/robustness.rs index a4027f15d..0c5081402 100644 --- a/crates/vp8/tests/robustness.rs +++ b/crates/vp8/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! Damaged, truncated, reordered and random input must never panic (errors are fine). -mod common; +use crate::common; use filmcraft_vp8::{Decoder, Error}; diff --git a/crates/vp8/tests/vectors.rs b/crates/vp8/tests/it/vectors.rs similarity index 99% rename from crates/vp8/tests/vectors.rs rename to crates/vp8/tests/it/vectors.rs index 789a40f56..d992c1926 100644 --- a/crates/vp8/tests/vectors.rs +++ b/crates/vp8/tests/it/vectors.rs @@ -3,7 +3,7 @@ //! downloaded by the tests; fetch them from //! `https://storage.googleapis.com/downloads.webmproject.org/test_data/libvpx/`. -mod common; +use crate::common; /// MD5 (RFC 1321), for comparing with the vectors' checksums. fn md5(data: &[u8]) -> String { diff --git a/crates/vp9/README.md b/crates/vp9/README.md index aea3a7781..765eb4b0c 100644 --- a/crates/vp9/README.md +++ b/crates/vp9/README.md @@ -115,16 +115,16 @@ draft mode. - Draft mode (`draft_mode_changes_only_flagged_non_reference_frames`): on a two-layer temporal scalability stream, single- and frame-threaded, every unflagged frame is bit-exact and only flagged frames differ. -- Conformance (`tests/conformance.rs`): the fixture matrix below plus a stream concatenating two +- Conformance (`tests/it/conformance.rs`): the fixture matrix below plus a stream concatenating two sizes. `compare_detects_mismatch` guards the harness. `coverage_report` (ignored) prints the decoder statistics for each fixture. -- Synthetic (`tests/synthetic.rs`): headers of libvpx output are rewritten to produce what ffmpeg's +- Synthetic (`tests/it/synthetic.rs`): headers of libvpx output are rewritten to produce what ffmpeg's libvpx wrapper cannot: inter frames of a stream encoded at another size spliced after a 352x288 key frame with explicit frame sizes, i.e. **scaled reference prediction** (downscale 256x208, upscale 480x400, odd 301x199; a same-size splice is the control); a key frame rewritten as a hidden **intra-only frame** (reset_frame_context 3 / 2 / 0, various refresh masks) shown with **show_existing_frame**; show_existing_frame of every slot, also inside a superframe. -- Robustness (`tests/robustness.rs`): bit flips, byte replacement and truncation of real streams, +- Robustness (`tests/it/robustness.rs`): bit flips, byte replacement and truncation of real streams, truncated frames, random data and oversized frames never panic (debug and release) or hang, single- and multi-threaded. @@ -158,7 +158,7 @@ Backward adaptation is on (`-frame-parallel 0`) unless noted. ## Performance -`cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture` (bit-exactness is +`cargo test --release -p filmcraft-vp9 --test it perf:: -- --ignored --nocapture` (bit-exactness is verified first), or `VP9_THREADS=n VP9_LOOPS=k cargo run --release -p filmcraft-vp9 --example vp9dec -- in.ivf` (decodes the file k times and prints the statistics; `VP9_DRAFT=1` for draft mode). diff --git a/crates/vp9/tests/fuzz_hostile.rs b/crates/vp9/tests/fuzz_hostile.rs index 70f884ec0..a04227331 100644 --- a/crates/vp9/tests/fuzz_hostile.rs +++ b/crates/vp9/tests/fuzz_hostile.rs @@ -90,14 +90,63 @@ fn run_all(frames: &[Vec], label: &str) { 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) +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 200 truncation +/// points spread over each of the first three frames, every header byte, overwrite value and +/// bit flip) plus N random rounds per seed: 300 is the full depth, more hunts longer. Unset, CI +/// runs a third of the truncation points and of the full depth's random rounds and, for each +/// header byte of each frame, one value and one bit, shifted by the frame index. A rotation +/// offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC day number), so every +/// case of the full sweep runs within a few days, and a random round's mutations match the same +/// round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 200, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=300 runs everything)"); + Depth { full: false, truncations: 200, rounds: 300, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, fi: usize, p: usize, i: usize) -> bool { + self.full || i == (p + fi + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, fi: usize, p: usize, b: usize) -> bool { + self.full || b == (p + fi + self.rotation % 8) % 8 + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) } /// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). const DEADLINE_SECS: u64 = 600; +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=300` runs all of it (see [`Depth`]). #[test] fn hostile_streams_never_panic() { filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); @@ -105,6 +154,7 @@ fn hostile_streams_never_panic() { fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP9 fuzz") else { return }; + let depth = Depth::from_env(); for &(name, args, pix) in SEEDS { if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx-vp9") { filmcraft_testkit::skip("VP9 fuzz", &format!("this ffmpeg has no libvpx-vp9 encoder (seed {name})")); @@ -120,7 +170,7 @@ fn sweep() { // 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)) { + for n in depth.truncations(len) { let mut d = seed.clone(); d[fi].truncate(n); run_all(&d, &format!("{name} frame {fi} truncate {n}")); @@ -129,12 +179,18 @@ fn sweep() { // 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] { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(fi, p, i) { + continue; + } let mut d = seed.clone(); d[fi][p] = v; run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}")); } for b in 0..8 { + if !depth.bit(fi, p, b) { + continue; + } let mut d = seed.clone(); d[fi][p] ^= 1 << b; run_all(&d, &format!("{name} frame {fi} flip {p}.{b}")); @@ -142,7 +198,8 @@ fn sweep() { } } let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); - for i in 0..iters() { + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { let mut d = seed.clone(); for _ in 0..1 + rng.below(6) { let fi = rng.below(d.len()); @@ -156,7 +213,9 @@ fn sweep() { d[fi][p] = rng.next() as u8; } } - run_all(&d, &format!("{name} random {i}")); + if depth.round(i) { + run_all(&d, &format!("{name} random {i}")); + } } } } diff --git a/crates/vp9/tests/common/mod.rs b/crates/vp9/tests/it/common/mod.rs similarity index 100% rename from crates/vp9/tests/common/mod.rs rename to crates/vp9/tests/it/common/mod.rs diff --git a/crates/vp9/tests/conformance.rs b/crates/vp9/tests/it/conformance.rs similarity index 99% rename from crates/vp9/tests/conformance.rs rename to crates/vp9/tests/it/conformance.rs index ceb875e0b..58a7e21ad 100644 --- a/crates/vp9/tests/conformance.rs +++ b/crates/vp9/tests/it/conformance.rs @@ -1,7 +1,7 @@ //! Bit-exactness against ffmpeg's VP9 decoder on libvpx-vp9 fixtures (skipped when ffmpeg or //! libvpx is absent). -mod common; +use crate::common; macro_rules! fixture_tests { ($($name:ident),* $(,)?) => { diff --git a/crates/vp9/tests/it/main.rs b/crates/vp9/tests/it/main.rs new file mode 100644 index 000000000..c26f9adcf --- /dev/null +++ b/crates/vp9/tests/it/main.rs @@ -0,0 +1,5 @@ +mod common; +mod conformance; +mod perf; +mod robustness; +mod synthetic; diff --git a/crates/vp9/tests/perf.rs b/crates/vp9/tests/it/perf.rs similarity index 92% rename from crates/vp9/tests/perf.rs rename to crates/vp9/tests/it/perf.rs index 162392ba1..ad427ea1a 100644 --- a/crates/vp9/tests/perf.rs +++ b/crates/vp9/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode speed (ignored by default; run with -//! `cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-vp9 --test it perf:: -- --ignored --nocapture`). -mod common; +use crate::common; use filmcraft_vp9::Decoder; use std::time::Instant; diff --git a/crates/vp9/tests/robustness.rs b/crates/vp9/tests/it/robustness.rs similarity index 99% rename from crates/vp9/tests/robustness.rs rename to crates/vp9/tests/it/robustness.rs index be63331d9..80db3d0fb 100644 --- a/crates/vp9/tests/robustness.rs +++ b/crates/vp9/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! Damaged, truncated and random input must never panic or hang (errors are fine). -mod common; +use crate::common; use filmcraft_vp9::Decoder; use std::time::{Duration, Instant}; diff --git a/crates/vp9/tests/synthetic.rs b/crates/vp9/tests/it/synthetic.rs similarity index 96% rename from crates/vp9/tests/synthetic.rs rename to crates/vp9/tests/it/synthetic.rs index a01c7a1b8..64094f800 100644 --- a/crates/vp9/tests/synthetic.rs +++ b/crates/vp9/tests/it/synthetic.rs @@ -7,7 +7,7 @@ //! show_existing_frame; //! - show_existing_frame of hidden alt-ref frames and of arbitrary slots. -mod common; +use crate::common; use std::path::Path; @@ -236,10 +236,15 @@ fn encode(name: &str, w: u32, h: u32, frames: u32, extra: &[&str]) -> Option` writes `view-*.png`) | -| Settings | `crates/engine/src/settings_tests.rs`, `crates/ui-egui/tests/settings_ui.rs` | schema keys and defaults, persistence in the data directory, v1 → v2 migration and repair of bad values, validation, per-category reset; each wired setting (still / transition durations, step many, label defaults and colours, media scaling, timebase, recent projects, media cache policy, output mapping, smart quotes, auto-transcribe); the dialog: every category from its command and Cmd+,, edits by automation id, OK / Cancel / Escape / Reset…, theme, label names in Edit ▸ Label, tooltips, frame cache (`FILMCRAFT_UI_SHOTS=` writes `settings-*.png`) | -| Scopes and panels (M8.9 / M12.6) | `crates/scopes/src/tests.rs`, `crates/engine/src/{scopes,panels}_tests.rs`, `crates/ui-egui/tests/panels_ui.rs` | scope maths on generated frames (flat colours → exact histogram bins, ramps → waveform row = code value, 75 % bars → parade levels and vectorscope target cells for BT.601/709/2020, HLS hue angles, YC chroma, Clamp Signal, decimation, NaN); `scopes.read` on a colour matte (exact bins, 78.43 %) and on Bars and Tone (the six targets); metadata edits (one undo step, read-only fields refused, saved in the project), the event log (failed / disabled commands, repeats, jobs started / finished / failed / cancelled); every panel from Window ▸, the scopes' wrench menu, presets, five-scope grid, typing into a Metadata field, Timecode rows and modes, Events filter / Clear All, Progress cancel, Reference Monitor park / gang / scopes (`FILMCRAFT_UI_SNAPSHOT_DIR=` writes `panels-*.png`); `perf_scopes_at_1080p` (ignored) times each scope | -| Keyframe navigator and Effect Controls ruler | `crates/engine/src/tests.rs` (`add_keyframe_toggles_the_keyframe_at_the_playhead`), `crates/ui-egui/tests/keyframes_ui.rs`, `lane_tests` in `crates/ui-egui/src/panels/effect_controls.rs` | `effects.addKeyframe` adds a keyframe at the playhead or removes the one there (the value falls back to the remaining keyframes; undo, redo, parameters that name nothing); headless: the Properties diamond turns animation on, adds and removes the keyframe at the playhead (Scale 50 % → 100 % again), its ◀ ▶ arrows and Effect Controls' step between keyframes, Crop is applied before its first keyframe; a click on the Effect Controls time ruler and a drag of the playhead's handle move the playhead, and no playhead is drawn off the clip; ruler labels are sequence timecode, and clip times, frame rates and panel widths at their limits don't panic (`FILMCRAFT_UI_SHOTS=` writes `keyframes-*.png`) | +| Scripted UI | `crates/ui-egui/tests/it/scripted.rs` | headless app (egui_kittest) driven over the control channel: razor/undo, insert, apply effect, playback, click by automation id (§4) | +| Monitor view / Graphics menu | `crates/ui-egui/tests/it/view_ui.rs`, `crates/engine/src/graphics_tests.rs` | View menu items and checkmarks, playback/paused resolution, channel / comparison / multi-camera / waveform display modes, magnification and Hand-tool panning, guides dragged from the rulers (move, lock, remove, Add Guide…, templates in the preferences), snapping a graphic to the frame centre and a guide; align to frame / as group / to selection, distribute (centres and gaps), arrange, select next/previous graphic and layer, reset parameters / duration, vertical text, New Layer from file (`FILMCRAFT_UI_SHOTS=` writes `view-*.png`) | +| Settings | `crates/engine/src/settings_tests.rs`, `crates/ui-egui/tests/it/settings_ui.rs` | schema keys and defaults, persistence in the data directory, v1 → v2 migration and repair of bad values, validation, per-category reset; each wired setting (still / transition durations, step many, label defaults and colours, media scaling, timebase, recent projects, media cache policy, output mapping, smart quotes, auto-transcribe); the dialog: every category from its command and Cmd+,, edits by automation id, OK / Cancel / Escape / Reset…, theme, label names in Edit ▸ Label, tooltips, frame cache (`FILMCRAFT_UI_SHOTS=` writes `settings-*.png`) | +| Scopes and panels (M8.9 / M12.6) | `crates/scopes/src/tests.rs`, `crates/engine/src/{scopes,panels}_tests.rs`, `crates/ui-egui/tests/it/panels_ui.rs` | scope maths on generated frames (flat colours → exact histogram bins, ramps → waveform row = code value, 75 % bars → parade levels and vectorscope target cells for BT.601/709/2020, HLS hue angles, YC chroma, Clamp Signal, decimation, NaN); `scopes.read` on a colour matte (exact bins, 78.43 %) and on Bars and Tone (the six targets); metadata edits (one undo step, read-only fields refused, saved in the project), the event log (failed / disabled commands, repeats, jobs started / finished / failed / cancelled); every panel from Window ▸, the scopes' wrench menu, presets, five-scope grid, typing into a Metadata field, Timecode rows and modes, Events filter / Clear All, Progress cancel, Reference Monitor park / gang / scopes (`FILMCRAFT_UI_SNAPSHOT_DIR=` writes `panels-*.png`); `perf_scopes_at_1080p` (ignored) times each scope | +| Keyframe navigator and Effect Controls ruler | `crates/engine/src/tests.rs` (`add_keyframe_toggles_the_keyframe_at_the_playhead`), `crates/ui-egui/tests/it/keyframes_ui.rs`, `lane_tests` in `crates/ui-egui/src/panels/effect_controls.rs` | `effects.addKeyframe` adds a keyframe at the playhead or removes the one there (the value falls back to the remaining keyframes; undo, redo, parameters that name nothing); headless: the Properties diamond turns animation on, adds and removes the keyframe at the playhead (Scale 50 % → 100 % again), its ◀ ▶ arrows and Effect Controls' step between keyframes, Crop is applied before its first keyframe; a click on the Effect Controls time ruler and a drag of the playhead's handle move the playhead, and no playhead is drawn off the clip; ruler labels are sequence timecode, and clip times, frame rates and panel widths at their limits don't panic (`FILMCRAFT_UI_SHOTS=` writes `keyframes-*.png`) | | Golden images | `crates/golden/tests/golden.rs` | CPU renders vs committed PNGs; GPU vs CPU on the same scenes (§3) | | Engine / command | `crates/engine/src/tests.rs` | run commands on the demo project, assert the sequence, undo/redo, disabled cases | | Render | `crates/render/src/tests.rs` | compositing, opacity, Motion, cross dissolve midpoint, ½-res vs full, GPU plan vs reference, audio mix, audio-effect continuity | -| Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; 20 angles: paging (auto / 2×2 / 3×3), page-relative keys, absolute cameras beyond 16, a page decoding only its angles at the cell scale (recording source), Auto-Adjust scale, Selection Top Down; headless: page arrows, angle 17–20 cells, grid-only view, Edit Cameras thumbnails; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost | -| Project panel and Media Browser (M12.7) | `crates/engine/src/{project_panel,media_browser}_tests.rs`, `crates/ui-egui/tests/project_panel_ui.rs` | view settings and their preference round trip; Metadata Display columns (custom fields, Freeform keys hidden), numeric vs text sorting, view presets (save / save as / restore / rename / delete, ten slots, persisted in `preferences.json`); Freeform moves keeping offsets, snapping, Align to Grid, Clip Size, stacks, arrangements, Reset (each one undo step, saved in the project); bin rename / nesting; the Media Browser against a fake filesystem (folders first, hidden and unsupported files left out, file-type filter, back / forward / up, recent directories, Favorites, import of a selection or a folder, Open in Source Monitor, ingest, probe columns). Headless: List view (sort by header, drag a column wider, inline rename), Icon view (hover scrub + I / O set the clip's In / Out, Shift+] / [), Freeform (drag a card, stack from the card menu, options dialog), panel menu, Metadata Display dialog, view presets, bins in place / tab / window, footer buttons, Media Browser navigation, Favorites, import, Shift+O, Edit Columns…, thumbnails, drag to the Timeline (`FILMCRAFT_UI_SNAPSHOT_DIR=` writes `project-*.png`, `media-browser-*.png`) | -| Menu long tail (M3.11) | `crates/render/src/scene.rs`, `crates/engine/src/{scene_detect,sequence_extras,project_tools}_tests.rs`, `crates/interchange/src/ale.rs`, `crates/project/src/find.rs`, `crates/ui-egui/tests/menus_ui.rs` | Scene Edit Detection on synthetic frames (hard cuts vs pans, flashes, sensitivity) and on generated movies with known cut points (our own ProRes render of demo scenes; an ffmpeg-made H.264 concat of testsrc2 / SMPTE bars / Mandelbrot): cuts with linked audio, clip markers, subclips, one undo step, background job and cancel; Normalize Mix Track to a target peak; Simplify Sequence; Find / Find Next and search bins; Automate to Sequence (overlap, transitions, insert, unnumbered markers); ALE round trip; Project Settings and scratch disks; menu order and shortcuts against Premiere's; the dialogs by automation id | +| Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/it/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; 20 angles: paging (auto / 2×2 / 3×3), page-relative keys, absolute cameras beyond 16, a page decoding only its angles at the cell scale (recording source), Auto-Adjust scale, Selection Top Down; headless: page arrows, angle 17–20 cells, grid-only view, Edit Cameras thumbnails; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost | +| Project panel and Media Browser (M12.7) | `crates/engine/src/{project_panel,media_browser}_tests.rs`, `crates/ui-egui/tests/it/project_panel_ui.rs` | view settings and their preference round trip; Metadata Display columns (custom fields, Freeform keys hidden), numeric vs text sorting, view presets (save / save as / restore / rename / delete, ten slots, persisted in `preferences.json`); Freeform moves keeping offsets, snapping, Align to Grid, Clip Size, stacks, arrangements, Reset (each one undo step, saved in the project); bin rename / nesting; the Media Browser against a fake filesystem (folders first, hidden and unsupported files left out, file-type filter, back / forward / up, recent directories, Favorites, import of a selection or a folder, Open in Source Monitor, ingest, probe columns). Headless: List view (sort by header, drag a column wider, inline rename), Icon view (hover scrub + I / O set the clip's In / Out, Shift+] / [), Freeform (drag a card, stack from the card menu, options dialog), panel menu, Metadata Display dialog, view presets, bins in place / tab / window, footer buttons, Media Browser navigation, Favorites, import, Shift+O, Edit Columns…, thumbnails, drag to the Timeline (`FILMCRAFT_UI_SNAPSHOT_DIR=` writes `project-*.png`, `media-browser-*.png`) | +| Menu long tail (M3.11) | `crates/render/src/scene.rs`, `crates/engine/src/{scene_detect,sequence_extras,project_tools}_tests.rs`, `crates/interchange/src/ale.rs`, `crates/project/src/find.rs`, `crates/ui-egui/tests/it/menus_ui.rs` | Scene Edit Detection on synthetic frames (hard cuts vs pans, flashes, sensitivity) and on generated movies with known cut points (our own ProRes render of demo scenes; an ffmpeg-made H.264 concat of testsrc2 / SMPTE bars / Mandelbrot): cuts with linked audio, clip markers, subclips, one undo step, background job and cancel; Normalize Mix Track to a target peak; Simplify Sequence; Find / Find Next and search bins; Automate to Sequence (overlap, transitions, insert, unnumbered markers); ALE round trip; Project Settings and scratch disks; menu order and shortcuts against Premiere's; the dialogs by automation id | | Mixer | `crates/render/src/mixer_tests.rs`, `crates/engine/src/mixer_tests.rs` | sample-exact fader gain and pan laws, automation at block boundaries (bit-identical however requests are cut), solo/mute/solo-safe, sends and submix routing, latency-compensation alignment, render-vs-playback identity, Touch ramp-back, recorder modes (Latch/Touch/Write), thinning, Audio Gain, transition curves; `perf_24_tracks_3_effects_realtime_factor` prints the realtime factor | -| 5.1 / multichannel | `crates/audio-dsp/src/channels.rs`, `crates/render/src/mixer_tests.rs`, `crates/export/src/surround_tests.rs`, `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | BS.775 downmix / upmix coefficients exact, mixdown types; 5.1 panner point gains equal-power (Σg² = 1 over a 21×21 grid × 3 centre settings), speaker positions exact, 5.1 through the default puck bit-exact identity; graph: stereo/mono tracks into a 5.1 Mix (L/R, C, rear right, room centre −6 dB each, LFE = mean), 5.1 track through a 5.1 Mix and folded into stereo, a 5.1 submix, `pan51` automation block-invariant, six-channel meters; export: 6-ch WAV (`WAVE_FORMAT_EXTENSIBLE`, mask 0x3F) and ProRes MOV (6 × 24-bit PCM) decoded by ffmpeg **sample-exact**, ffprobe `channels` 6 / `channel_layout` 5.1; AAC 5.1 in MP4: every decoded channel's own tone ≥ 30 dB above the others' (no swapped channels), stereo export of a 5.1 Mix = BS.775 within 2·10⁻⁶; `sequence.settings {mix}`, `file.newSequence {mix, trackType}`, panner lanes, Write pass records the puck; UI: puck drag (one undo step), Center / LFE, six meter bars | -| Audio Clip Mixer automation, Track Mixer view | `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | Touch: a 201-point fader ramp thins to ≤ 6 clip keyframes, values within 0.1 dB, live override while held, values outside the gesture unchanged, one undo step; Latch holds until stop; Write records volume and pan from the start; a gesture across two clips writes both; Read commits once without a pass; UI: Clip Mixer mode dropdown, a recorded fader drag writes clip keyframes; Show/Hide Tracks, Meter Input(s) Only, transport record button | -| Voice-over | `crates/engine/src/voiceover_tests.rs`, `crates/ui-egui/tests/voiceover_ui.rs` | synthetic click train lands in `mix_sequence` exactly at R + k·period (pre-roll dropped, exact sample count), punch-in between In/Out (ramp source, every sample checked), one undo step + undo/redo, take numbering, record-arm/targeting track choice, sync/discard/zero-length/locked cases, cue times; UI: right-click Mic → Voice-Over Record Settings dialog (edits persist on OK, Cancel discards), the Mic button starts and stops a take on its track with pre-roll and overlay, and records past the end of the timeline (also `voiceover_playback_tests` in `crates/ui-egui/src/lib.rs`) | -| Text to Speech | `crates/tts/src/**/*.rs` (Kokoro tests need `FILMCRAFT_KOKORO_DIR`), `crates/tts-text`, `crates/engine/src/narration_tests.rs`, `crates/ui-egui/tests/tts_ui.rs` | built-in voices: determinism, finite samples, exact-silence pause markers, pace scales length, measured pitch per voice and per semitone, hostile scripts; engine: narration placed at the playhead with clip length = speech (sample-exact), never overwrites (busy tracks → next free track → new track), occupied/locked/unknown track refused with no change, edit keeps start/length/speed (longer speech: whole speech in the file; shorter: file padded with exact zeros), edits write beside the previous file, undo/redo restore the project exactly, enabled only with a narration clip (or `clip`), 16 hostile parameter sets refused without change, preview changes nothing, save/reload keeps narrations, the narration is audible in `mix_sequence`; UI: type + Add pause + Add to timeline, the new clip is selected and loaded, clicking the clip on the timeline loads its script, Save edits it in place as one undo step, Advanced twirl, panel opens beside Properties; natural voices: 50-sentence Whisper-base WER 2.1–3.6 % for all 9 voices (crate README), loaders fuzzed with hostile files, `tts.render` cache, download refused without the feature | -| Remix | `crates/audio-dsp/src/remix.rs`, `crates/render/src/remix.rs`, `crates/engine/src/remix_tests.rs`, `crates/ui-egui/tests/remix_ui.rs` | generated rhythmic music (Rust-synthesised drums, bass, chord sections at 90–128 BPM, 22.05/44.1/48 kHz): tempo within 0.3 BPM, every beat within 10 ms (measured ≤ 8.9 ms); remix targets from 0.4× to 2.2× the source within one beat (measured ≤ 12 ms at default sliders), every cut on a detected beat, intro and outro kept, pieces ≥ 4 beats, deterministic; silence, tone and noise refused; rendering independent of request cuts, source-exact outside the 20 ms equal-power crossfades, no clicks; the engine's mix equals the source pieces (< 1e-5); undo, redo and revert exact; overlaps refused; Remix Properties dialog and Remix tool drag driven by automation id | -| Oracle | `crates/*/tests/*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) | -| Robustness / fuzz | `*/tests/robustness.rs`, `*/tests/fuzz.rs` | seeded mutation and truncation of real and synthetic files; nothing may panic | -| Program monitor while dragging | `crates/ui-egui/tests/perf_ui.rs` | headless: a parameter changed on every UI pass (a value dragged in Effect Controls or Properties) and a backward scrub leave the Program monitor's picture at most one step behind (it shows the frame asked for the pass before while the frame due renders; it used to freeze until the mouse rested); a frame still rendering is kept when the same frame is asked for at a newer revision, so frames slower than a refresh (CPU effects) are shown too; `perf.stats` reports the picture's frame and revision against the playhead and the project | -| Performance | `*/tests/perf.rs` (`#[ignore]`) | §5 | +| 5.1 / multichannel | `crates/audio-dsp/src/channels.rs`, `crates/render/src/mixer_tests.rs`, `crates/export/src/surround_tests.rs`, `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/it/audio51_ui.rs` | BS.775 downmix / upmix coefficients exact, mixdown types; 5.1 panner point gains equal-power (Σg² = 1 over a 21×21 grid × 3 centre settings), speaker positions exact, 5.1 through the default puck bit-exact identity; graph: stereo/mono tracks into a 5.1 Mix (L/R, C, rear right, room centre −6 dB each, LFE = mean), 5.1 track through a 5.1 Mix and folded into stereo, a 5.1 submix, `pan51` automation block-invariant, six-channel meters; export: 6-ch WAV (`WAVE_FORMAT_EXTENSIBLE`, mask 0x3F) and ProRes MOV (6 × 24-bit PCM) decoded by ffmpeg **sample-exact**, ffprobe `channels` 6 / `channel_layout` 5.1; AAC 5.1 in MP4: every decoded channel's own tone ≥ 30 dB above the others' (no swapped channels), stereo export of a 5.1 Mix = BS.775 within 2·10⁻⁶; `sequence.settings {mix}`, `file.newSequence {mix, trackType}`, panner lanes, Write pass records the puck; UI: puck drag (one undo step), Center / LFE, six meter bars | +| Audio Clip Mixer automation, Track Mixer view | `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/it/audio51_ui.rs` | Touch: a 201-point fader ramp thins to ≤ 6 clip keyframes, values within 0.1 dB, live override while held, values outside the gesture unchanged, one undo step; Latch holds until stop; Write records volume and pan from the start; a gesture across two clips writes both; Read commits once without a pass; UI: Clip Mixer mode dropdown, a recorded fader drag writes clip keyframes; Show/Hide Tracks, Meter Input(s) Only, transport record button | +| Voice-over | `crates/engine/src/voiceover_tests.rs`, `crates/ui-egui/tests/it/voiceover_ui.rs` | synthetic click train lands in `mix_sequence` exactly at R + k·period (pre-roll dropped, exact sample count), punch-in between In/Out (ramp source, every sample checked), one undo step + undo/redo, take numbering, record-arm/targeting track choice, sync/discard/zero-length/locked cases, cue times; UI: right-click Mic → Voice-Over Record Settings dialog (edits persist on OK, Cancel discards), the Mic button starts and stops a take on its track with pre-roll and overlay, and records past the end of the timeline (also `voiceover_playback_tests` in `crates/ui-egui/src/lib.rs`) | +| Text to Speech | `crates/tts/src/**/*.rs` (Kokoro tests need `FILMCRAFT_KOKORO_DIR`), `crates/tts-text`, `crates/engine/src/narration_tests.rs`, `crates/ui-egui/tests/it/tts_ui.rs` | built-in voices: determinism, finite samples, exact-silence pause markers, pace scales length, measured pitch per voice and per semitone, hostile scripts; engine: narration placed at the playhead with clip length = speech (sample-exact), never overwrites (busy tracks → next free track → new track), occupied/locked/unknown track refused with no change, edit keeps start/length/speed (longer speech: whole speech in the file; shorter: file padded with exact zeros), edits write beside the previous file, undo/redo restore the project exactly, enabled only with a narration clip (or `clip`), 16 hostile parameter sets refused without change, preview changes nothing, save/reload keeps narrations, the narration is audible in `mix_sequence`; UI: type + Add pause + Add to timeline, the new clip is selected and loaded, clicking the clip on the timeline loads its script, Save edits it in place as one undo step, Advanced twirl, panel opens beside Properties; natural voices: 50-sentence Whisper-base WER 2.1–3.6 % for all 9 voices (crate README), loaders fuzzed with hostile files, `tts.render` cache, download refused without the feature | +| Remix | `crates/audio-dsp/src/remix.rs`, `crates/render/src/remix.rs`, `crates/engine/src/remix_tests.rs`, `crates/ui-egui/tests/it/remix_ui.rs` | generated rhythmic music (Rust-synthesised drums, bass, chord sections at 90–128 BPM, 22.05/44.1/48 kHz): tempo within 0.3 BPM, every beat within 10 ms (measured ≤ 8.9 ms); remix targets from 0.4× to 2.2× the source within one beat (measured ≤ 12 ms at default sliders), every cut on a detected beat, intro and outro kept, pieces ≥ 4 beats, deterministic; silence, tone and noise refused; rendering independent of request cuts, source-exact outside the 20 ms equal-power crossfades, no clicks; the engine's mix equals the source pieces (< 1e-5); undo, redo and revert exact; overlaps refused; Remix Properties dialog and Remix tool drag driven by automation id | +| Oracle | `crates/*/tests/{,it/}*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) | +| Robustness / fuzz | `*/tests/{,it/}robustness.rs`, `*/tests/{,it/}fuzz.rs` | seeded mutation and truncation of real and synthetic files; nothing may panic | +| Program monitor while dragging | `crates/ui-egui/tests/it/perf_ui.rs` | headless: a parameter changed on every UI pass (a value dragged in Effect Controls or Properties) and a backward scrub leave the Program monitor's picture at most one step behind (it shows the frame asked for the pass before while the frame due renders; it used to freeze until the mouse rested); a frame still rendering is kept when the same frame is asked for at a newer revision, so frames slower than a refresh (CPU effects) are shown too; `perf.stats` reports the picture's frame and revision against the playhead and the project | +| Performance | `*/tests/{,it/}perf.rs` (`#[ignore]`) | §5 | Commit `*.proptest-regressions` files so failing cases are re-run. @@ -52,7 +60,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). - If a tool is missing the test prints `SKIPPED (): ffmpeg not found …` and passes. Set **`FILMCRAFT_REQUIRE_ORACLES=1`** (CI) to make every such skip a failure. In new tests use `let ff = filmcraft_testkit::require_ffmpeg!();` (also `require_ffprobe!`, `require_oracles!`). -- Some oracles need a recent ffmpeg: the APV tests (`crates/apv/tests/oracle.rs`) skip when +- Some oracles need a recent ffmpeg: the APV tests (`crates/apv/tests/it/oracle.rs`) skip when `ffmpeg -formats` does not list `apv` (ffmpeg ≥ 8.0), naming the detected version. - Fixtures are generated on first use into `/target/fixtures//` (`filmcraft_testkit::fixtures_dir`; independent of `CARGO_TARGET_DIR`, so agents with private @@ -71,12 +79,12 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). the matrix. - Fixture sources are synthetic: `testsrc2`, `mandelbrot`, SMPTE bars, noise and fades, sine tones. H.264 and HEVC fixtures need ffmpeg built with libx264 and libx265. VideoToolbox fixtures are - generated only on macOS. The Media Foundation parity tests (`crates/platform/tests/media_foundation.rs`, + generated only on macOS. The Media Foundation parity tests (`crates/platform/tests/it/media_foundation.rs`, Windows only) make their 640x360, 1080p and 2160p H.264 / HEVC / Main 10 fixtures with ffmpeg and skip without a Direct3D 11 video device or the HEVC / VP9 / AV1 codec extensions of the Microsoft Store. The VP9 and AV1 fixtures (libvpx-vp9, libaom-av1; 360p, 1080p, 2160p, hidden alt-ref frames, two GOPs) need an ffmpeg with those encoders. -The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_rgba_input.rs` for +The NVENC tests (`crates/platform/tests/it/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_rgba_input.rs` for the GPU's RGB → 4:2:0 conversion, `nvenc_hevc.rs`, `nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265, `nvenc_hevc_main10.rs` and `nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows only) skip, printing `SKIPPED`, without an NVIDIA GPU @@ -173,7 +181,7 @@ Each codec README has the full fixture matrix and the measured results. ### Scripted UI tests (headless) -`crates/ui-egui/tests/scripted.rs` runs the real `FilmcraftApp` under +`crates/ui-egui/tests/it/scripted.rs` runs the real `FilmcraftApp` under [`egui_kittest`](https://docs.rs/egui_kittest) (`build_eframe`): no window, no GPU, no OS event loop. A small `Driver` opens the demo project, sends requests through the same control channel agents use (`ControlRequest` → `control::handle`, exactly as the TCP server does) and steps egui frames until @@ -190,15 +198,15 @@ next frame by the app's own `raw_input_hook`, so clicks by automation id work he | `clicking_a_tool_button_by_automation_id` | `ui.click {id: "tools.Razor"}` changes the tool (real egui input path) | | `unknown_methods_and_commands_fail_cleanly` | errors come back as `{"ok": false}` | -`crates/ui-egui/tests/mixer_ui.rs` drives the Audio Track Mixer, Audio Clip Mixer, timeline track +`crates/ui-egui/tests/it/mixer_ui.rs` drives the Audio Track Mixer, Audio Clip Mixer, timeline track keyframes and the Audio Gain dialog by automation id and with multi-frame pointer drags (press, move over several frames, release). With `FILMCRAFT_UI_SNAPSHOT_DIR=` it also renders the window offscreen through wgpu (`Harness::render`) and writes `mixer-*.png`; this works without a visible window (for example on a locked screen, where `ui.screenshot` cannot capture). -`crates/ui-egui/tests/essential_sound_ui.rs` does the same for the Essential Sound panel (type buttons, +`crates/ui-egui/tests/it/essential_sound_ui.rs` does the same for the Essential Sound panel (type buttons, switches, a slider drag as one undo step, section bypass, Auto-Match, ducking, Browse presets; `essential-sound-*.png`). -`crates/ui-egui/tests/clip_audio_ui.rs` covers the audio of video clips (#223): the linked audio's +`crates/ui-egui/tests/it/clip_audio_ui.rs` covers the audio of video clips (#223): the linked audio's Volume, Channel Volume and Panner in Effect Controls and Properties, and the Volume line on audio clips in the Timeline (a drag is one undo step, Pen-tool keyframes, keyframe drags, a click or right-click on the line still reaches the clip; `FILMCRAFT_UI_SHOTS=` writes `volume-*.png`). @@ -214,12 +222,12 @@ and the WAV export equals it. `perf_full_dialogue_chain_realtime_factor` (ignored; run with `--release`) prints the realtime factor of all nine Dialogue effects on one clip (22× on one core). -`crates/ui-egui/tests/export_ui.rs` drives Export mode: the settings column and Summary, editing a +`crates/ui-egui/tests/it/export_ui.rs` drives Export mode: the settings column and Summary, editing a setting turning the preset into Custom, the Preset Manager (search, favourite star, save, apply, delete), the queue panel (Send to Queue, Up/Down, Cancel, Start, Retry, Clear) with real encodes, the Export button and the header's Quick Export popup (`export-*.png` snapshots). -`crates/ui-egui/tests/color_ui.rs` drives colour features the same way (`color-*.png` snapshots): +`crates/ui-egui/tests/it/color_ui.rs` drives colour features the same way (`color-*.png` snapshots): Lumetri Input/Look LUT menus and section switches, the Interpret Footage ▸ Color Management and Sequence Color Management dialogs, and the HDR scopes of a PQ sequence. @@ -258,12 +266,12 @@ engine behaviour without a window. | Benchmark | Command | |---|---| -| H.264 decode, 1080p, threads sweep | `cargo test --release -p filmcraft-h264 --test perf -- --ignored --nocapture` | -| HEVC decode, 1080p / 2160p | `cargo test --release -p filmcraft-hevc --test perf -- --ignored --nocapture` | -| ProRes decode/encode | `cargo test --release -p filmcraft-prores --test perf -- --ignored --nocapture` | +| H.264 decode, 1080p, threads sweep | `cargo test --release -p filmcraft-h264 --test it perf:: -- --ignored --nocapture` | +| HEVC decode, 1080p / 2160p | `cargo test --release -p filmcraft-hevc --test it perf:: -- --ignored --nocapture` | +| ProRes decode/encode | `cargo test --release -p filmcraft-prores --test it perf:: -- --ignored --nocapture` | | Encoder speed / PSNR / bitrate | `cargo run --release -p filmcraft-h264enc --example h264enc_synth -- …` | | Decode a real file through the media stack | `cargo run --release -p filmcraft-cli -- bench-decode file.mp4 --frames 120` | -| Coding-tool coverage of the fixtures | `cargo test --release -p filmcraft-h264 --test conformance coverage_report -- --ignored --nocapture` (same for `hevc`) | +| Coding-tool coverage of the fixtures | `cargo test --release -p filmcraft-h264 --test it conformance::coverage_report -- --ignored --nocapture` (same for `hevc`) | | **Whole-app suite**: decode fps per codec, playback, scrubbing, timeline UI, export, project save/open, peak RSS | `cargo xtask bench` ([performance.md](performance.md)) | `cargo xtask bench` (the example `crates/ui-egui/examples/bench/`) runs six sections, each in its diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 819326fcf..1e7c2dc7f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -16,7 +16,8 @@ //! lists, against a reference list (default: the local `plan/premiere/.json`), presence and //! placement reported separately (`parity.rs`, docs/gaps.md G7). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). -//! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. +//! - `ci`: fmt check, clippy -D warnings, tests (`cargo nextest run` when installed) and doctests, +//! layers, assets, wasm. mod ico; mod parity; @@ -259,7 +260,7 @@ const WEB_CRATES: &[&str] = &["filmcraft-ui-egui", "filmcraft-web"]; fn wasm() -> Result<(), String> { let md = metadata()?; let mut cmd = Command::new(env!("CARGO")); - cmd.args(["check", "--target", "wasm32-unknown-unknown"]); + cmd.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); let mut n = 0; for (name, _) in workspace_crates(&md) { if layer_of(&name).is_some_and(|l| l <= 4) || WEB_CRATES.contains(&name.as_str()) { @@ -400,26 +401,26 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } -/// (crate, test target) whose ignored `generate_fixtures` test builds that crate's fixture matrix. -const FIXTURE_GENERATORS: &[(&str, &str)] = &[ - ("h264", "conformance"), - ("hevc", "conformance"), - ("isobmff", "oracle_demux"), - ("matroska", "oracle"), - ("prores", "oracle_decode"), - ("dnx", "oracle_decode"), - ("codecs", "mxf_oracle"), - ("codecs", "mpeg_oracle"), - ("mpeg2v", "oracle"), - ("ac3", "oracle"), - ("mpa", "oracle"), - ("codecs", "ogg_oracle"), +/// (crate, test target, test) whose ignored test builds that crate's fixture matrix. +const FIXTURE_GENERATORS: &[(&str, &str, &str)] = &[ + ("h264", "it", "conformance::generate_fixtures"), + ("hevc", "it", "conformance::generate_fixtures"), + ("isobmff", "it", "oracle_demux::generate_fixtures"), + ("matroska", "oracle", "generate_fixtures"), + ("prores", "it", "oracle_decode::generate_fixtures"), + ("dnx", "it", "oracle_decode::generate_fixtures"), + ("codecs", "it", "mxf_oracle::generate_fixtures"), + ("codecs", "it", "mpeg_oracle::generate_fixtures"), + ("mpeg2v", "it", "oracle::generate_fixtures"), + ("ac3", "oracle", "generate_fixtures"), + ("mpa", "oracle", "generate_fixtures"), + ("codecs", "it", "ogg_oracle::generate_fixtures"), ]; fn fixtures(only: &[String]) -> Result<(), String> { - let unknown: Vec<&String> = only.iter().filter(|o| !FIXTURE_GENERATORS.iter().any(|(c, _)| *c == short(o))).collect(); + let unknown: Vec<&String> = only.iter().filter(|o| !FIXTURE_GENERATORS.iter().any(|(c, _, _)| *c == short(o))).collect(); if !unknown.is_empty() { - let known: Vec<&str> = FIXTURE_GENERATORS.iter().map(|(c, _)| *c).collect(); + let known: Vec<&str> = FIXTURE_GENERATORS.iter().map(|(c, _, _)| *c).collect(); return Err(format!("no fixture generator for {unknown:?} (known: {})", known.join(", "))); } let Some(ff) = filmcraft_testkit::ffmpeg() else { @@ -429,11 +430,11 @@ fn fixtures(only: &[String]) -> Result<(), String> { println!("fixtures: {}", filmcraft_testkit::fixtures::fixtures_root().display()); let (mut made, mut cached, mut skipped) = (0, 0, 0); let mut failed = Vec::new(); - for (krate, target) in FIXTURE_GENERATORS.iter().filter(|(c, _)| only.is_empty() || only.iter().any(|o| short(o) == *c)) { + for (krate, target, test) in FIXTURE_GENERATORS.iter().filter(|(c, _, _)| only.is_empty() || only.iter().any(|o| short(o) == *c)) { let pkg = format!("filmcraft-{krate}"); - println!("\n== {pkg} ({target})"); + println!("\n== {pkg} ({test})"); let mut cmd = Command::new(env!("CARGO")); - cmd.args(["test", "--release", "-p", &pkg, "--test", target, "--", "--ignored", "--exact", "generate_fixtures", "--nocapture"]); + cmd.args(["test", "--release", "-p", &pkg, "--test", target, "--", "--ignored", "--exact", test, "--nocapture"]); cmd.stderr(std::process::Stdio::inherit()); let out = cmd.output().map_err(|e| e.to_string())?; for line in String::from_utf8_lossy(&out.stdout).lines() { @@ -456,13 +457,28 @@ fn fixtures(only: &[String]) -> Result<(), String> { fn ci() -> Result<(), String> { let cargo = env!("CARGO"); run(Command::new(cargo).args(["fmt", "--check"]))?; - run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?; - run(Command::new(cargo).args(["test", "--workspace", "--release"]))?; + run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))?; + tests()?; layers()?; assets()?; wasm() } +/// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests, +/// which nextest doesn't run. +fn tests() -> Result<(), String> { + let cargo = env!("CARGO"); + let targets = ["--workspace", "--lib", "--bins", "--tests", "--no-fail-fast"]; + let nextest = Command::new(cargo).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success()); + if nextest { + run(Command::new(cargo).args(["nextest", "run"]).args(targets))?; + } else { + println!("cargo-nextest not found, running cargo test (install: cargo install cargo-nextest --locked)"); + run(Command::new(cargo).arg("test").args(targets))?; + } + run(Command::new(cargo).args(["test", "--workspace", "--doc"])) +} + /// The headless playback benchmark (`crates/ui-egui/examples/bench_playback.rs`); extra /// arguments go to the benchmark (`cargo xtask bench-playback --scenario stack3 --res full`). fn bench_playback() -> Result<(), String> { From 4743cfb9391e8bf29ab7838d2ba063e43826a3ac Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:37:55 -0400 Subject: [PATCH 049/110] fix(engine): resolve stored media paths with craft-docpath's resolve_absolute (#2609) * fix(engine): use resolve_absolute for stored media paths * fix(engine): give the reason when a scratch disk folder is refused * CI: check that git dependencies come from storytold * CI: release only with git dependencies from storytold * doc_path: a foreign drive path without a document is refused, not document-relative * Workflows: one Git-source check: lockfile present and current, no source replacement, storytold hosts only * Workflows: the Git-source check reads the legacy .cargo/config too * Workflows: the Git-source check parses cargo config with tomllib --- .github/workflows/nix.yml | 38 ++++++++++++++++++++ .github/workflows/release.yml | 40 +++++++++++++++++++++ crates/engine/src/doc_path.rs | 46 ++++++++++++++++++++---- crates/engine/src/project_tools.rs | 8 ++--- crates/engine/src/project_tools_tests.rs | 5 +++ 5 files changed, 126 insertions(+), 11 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 01cb9cd66..2fe7cd3ac 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,6 +18,44 @@ concurrency: cancel-in-progress: true jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + nix: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 436210aef..bdd7ab2d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,47 @@ concurrency: cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources # 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/release.yml@main with: diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs index 7168b181a..942d8f43c 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -39,15 +39,27 @@ impl std::error::Error for NotFollowed {} /// where it was opened from. pub fn resolve(stored: &str, document: Option<&Path>) -> Result { let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf())); - let path: PathBuf = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; - // A path that is still relative would be taken from the working directory, which the - // document does not choose. - if path.is_relative() { - return Err(NotFollowed::NoDocumentFolder); - } + let path: PathBuf = match craft_docpath::resolve_absolute(stored, document.as_deref()) { + Ok(path) => path, + // Without a document, a relative path resolves but stays relative. + Err(Refused::Invalid) if document.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| !foreign_root(&p)) => { + return Err(NotFollowed::NoDocumentFolder); + } + Err(r) => return Err(NotFollowed::Refused(r)), + }; path.into_os_string().into_string().map_err(|_| NotFollowed::Refused(Refused::Invalid)) } +/// Whether an unresolved `craft_docpath::resolve` result starts with a drive (`C:`) or a share +/// root (`\\`) outside Windows. Such a path names no file here, and no document folder makes it one. +fn foreign_root(path: &Path) -> bool { + if cfg!(windows) { + return false; + } + let b = path.as_os_str().as_encoded_bytes(); + b.starts_with(br"\\") || matches!((b.first(), b.get(1)), (Some(l), Some(b':')) if l.is_ascii_alphabetic()) +} + #[cfg(test)] mod tests { use super::*; @@ -60,5 +72,27 @@ mod tests { assert_eq!(resolve("clip.mp4", None), Err(NotFollowed::NoDocumentFolder)); assert_eq!(resolve(r"\\192.0.2.1\share\x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); assert_eq!(resolve("file://192.0.2.1/s/x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("jar:take.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Url))); + // Colons in names: an alternate data stream or a URL on Windows, a plain character elsewhere. + if cfg!(windows) { + assert_eq!(resolve("media/take:2.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); + assert_eq!(resolve("take:2.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Url))); + } else { + assert_eq!(resolve("media/take:2.mp4", Some(&doc)), Ok(dir.join("media").join("take:2.mp4").to_string_lossy().into_owned())); + assert_eq!(resolve("take:2.mp4", Some(&doc)), Ok(dir.join("take:2.mp4").to_string_lossy().into_owned())); + } + } + + /// Without a document a relative path waits for the document's folder; outside Windows a + /// drive path names no file and is refused. + #[test] + fn foreign_drive_paths_without_a_document_are_refused() { + for stored in ["clip.mp4", "../clip.mp4"] { + assert_eq!(resolve(stored, None), Err(NotFollowed::NoDocumentFolder), "{stored}"); + } + #[cfg(unix)] + for stored in [r"C:\x", r"C:x", "file:///C:/x/clip.mp4"] { + assert_eq!(resolve(stored, None), Err(NotFollowed::Refused(Refused::Invalid)), "{stored}"); + } } } diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index c5f967a75..136229370 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -1076,12 +1076,10 @@ fn project_settings_scratch(s: &mut Session, p: &Value) -> Result { None => {} Some(Value::Null) => *slot = None, Some(Value::String(x)) if x.is_empty() || x.eq_ignore_ascii_case("same as project") => *slot = None, - Some(Value::String(x)) - if matches!(crate::doc_path::resolve(x, s.path.as_deref().map(std::path::Path::new)), Err(crate::doc_path::NotFollowed::Refused(_))) => - { - return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` must be a folder on this computer, not a network share or device"))); - } Some(Value::String(x)) => { + if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(x, s.path.as_deref().map(std::path::Path::new)) { + return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` must be a folder on this computer: {why}"))); + } chosen.push(x.clone()); *slot = Some(x.clone()); } diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index 075525654..64965166c 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -307,6 +307,11 @@ fn media_properties_settings_and_scratch_disks() { assert!(s.execute("file.projectSettings.general", json!({"renderer": "quantum"})).is_err()); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.settings.renderer, crate::project_tools::RENDERER_GPU); + // Scratch Disks: a drive another system names is refused before the project has a folder + if cfg!(unix) { + assert!(s.execute("file.projectSettings.scratchDisks", json!({"captured": r"D:\Captured"})).is_err()); + assert!(s.project.settings.scratch.captured.is_none()); + } // Scratch Disks: previews follow the Video Previews folder once the project is saved let proj = dir.join("Show.fcproj").to_string_lossy().to_string(); s.execute("file.saveAs", json!({"path": proj})).unwrap(); From fbeccbe9bc5464dbc0fecbf0512b3e04c51f55dd Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:38:11 -0400 Subject: [PATCH 050/110] Fonts: pin craft-fonts bfcbf71; CRAFT_FONTS_REQUIRED fails without CRAFT_FONTS_DIR (#2646) * Fonts: pin craft-fonts bfcbf71; CRAFT_FONTS_REQUIRED fails without CRAFT_FONTS_DIR craft-fonts main adds Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai and Noto Sans Georgian. FilmCraft registers every CRAFT_FONTS entry, so releases pick them up with the pin bump. ATTRIBUTION.md and NOTICE list them. With CRAFT_FONTS_REQUIRED set, crates/text/build.rs errors when CRAFT_FONTS_DIR is unset or empty, not only when it is not a checkout. Refs storytold/craft-libs#197 * Fonts: only CRAFT_FONTS_REQUIRED=1 enables required mode * text: the CRAFT_FONTS_REQUIRED message says unset or empty --- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- ATTRIBUTION.md | 4 ++++ NOTICE | 2 +- crates/text/build.rs | 16 ++++++++++------ docs/contributing.md | 3 ++- docs/releasing.md | 4 ++-- 7 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0139df440..0ad9259d9 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -30,6 +30,6 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414 skip-tests: true run-package-check: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdd7ab2d4..546475f32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: with: app-name: filmcraft app-title: FilmCraft - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 48f5be394..e8a3a0aa2 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -130,6 +130,10 @@ carry each font's licence as `OFL-.txt`. | BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 | | Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 | | BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 | +| Noto Sans CJK SC Regular (Google, Adobe) | Chinese UI text and media names; Chinese fallback for titles (native builds) | OFL-1.1 | +| Noto Sans Arabic Regular (Google) | Arabic UI text; Arabic fallback for titles (native builds) | OFL-1.1 | +| Noto Sans Thai Regular (Google) | Thai fallback for titles (native builds) | OFL-1.1 | +| Noto Sans Georgian Regular (Google) | Georgian fallback for titles (native builds) | OFL-1.1 | ## First-party brand marks diff --git a/NOTICE b/NOTICE index 221a0acc6..d0d113091 100644 --- a/NOTICE +++ b/NOTICE @@ -17,5 +17,5 @@ Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fon JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-JetBrainsMono.txt. Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho, -BIZ UDMincho) — OFL-1.1. Their licence texts ship with the packages as OFL-.txt. +BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian) — OFL-1.1. Their licence texts ship with the packages as OFL-.txt. Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md. diff --git a/crates/text/build.rs b/crates/text/build.rs index 715a8b7df..17a5bf843 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -5,7 +5,8 @@ //! `crates/text`) this embeds every font listed in its //! `fonts/manifest.txt` as [`CRAFT_FONTS`](crate::fonts::CRAFT_FONTS); unset, `CRAFT_FONTS` is //! empty and nothing else changes. A `CRAFT_FONTS_DIR` that is not a checkout is a warning, or an -//! error with `CRAFT_FONTS_REQUIRED=1` (release builds). +//! error with `CRAFT_FONTS_REQUIRED=1` (release builds), which also makes an unset or empty +//! `CRAFT_FONTS_DIR` an error. //! //! Native builds embed every font. The web (wasm32) build embeds only the UI font (BIZ UDPGothic //! Regular, ~4.5 MB) to stay under the web size budget (Cloudflare serves files up to 25 MiB). @@ -18,15 +19,18 @@ fn main() { println!("cargo::rerun-if-changed=build.rs"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some_and(|v| v == "1"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); - if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - match craft_fonts(&dir) { + match std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from) { + Some(dir) => match craft_fonts(&dir) { Ok(entries) => src.push_str(&entries), - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } + Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()), Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), + }, + None if required => { + println!("cargo::error=CRAFT_FONTS_REQUIRED=1 but CRAFT_FONTS_DIR is unset or empty: point it at a craft-fonts checkout (absolute path)"); } + None => {} } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); diff --git a/docs/contributing.md b/docs/contributing.md index 84e99750d..b1e1222c7 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -89,7 +89,8 @@ Regular, to keep the wasm small). The Japanese ones become the last fallback of (`crates/ui-egui/src/theme.rs`, BIZ UDPGothic first) and fallback faces in the text engine (Gothic for sans text, Mincho for serif text). Without `CRAFT_FONTS_DIR`, `CRAFT_FONTS` is empty and FilmCraft uses its bundled and system fonts. A `CRAFT_FONTS_DIR` that is not a checkout is a build -warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both). Tests on these fonts' +warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both). With +`CRAFT_FONTS_REQUIRED=1`, a build without `CRAFT_FONTS_DIR` is an error too. Tests on these fonts' glyphs skip when it is empty; when you touch fonts, run the gates both with and without it. Chinese media names and track labels use the `Hans` / `Hant` craft-fonts faces after the Japanese diff --git a/docs/releasing.md b/docs/releasing.md index 58c283743..d3f482b41 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -59,8 +59,8 @@ signing jobs are refused, and the draft-release job, which needs them, is skippe `filmcraft --version` and `filmcraft-cli --version` print the version from `Cargo.toml`. **Fonts.** Every build job checks out [craft-fonts](https://github.com/storytold/craft-fonts) at a -pinned commit (the `ref:` of its craft-fonts checkout step, marked `# bump deliberately`) and builds -with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed its Japanese fonts and fail +pinned commit (`craft-fonts-ref` in `.github/workflows/release.yml` and `freebsd.yml`) and builds +with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail rather than ship without them (the web build embeds only the UI font, to stay small). The packages carry each embedded font's licence (`copy_font_licences` in `packaging/env.sh`). Bump the pins deliberately, in every job at once. From c3b4c4deb0db176de32fbac0ea0ce94cbc78d75a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:00:44 -0400 Subject: [PATCH 051/110] Packaging: pin the signing client and appimagetool, and check their hashes (#2610) * Packaging: pin the Windows signing client and check its hash * Packaging: pin appimagetool and check its hash * Packaging: an architecture without a pinned appimagetool hash fails early * Packaging: pin the AppImage runtime (type2-runtime 20251108) and pass it with --runtime-file * Packaging: assign the empty hashes with '' (shellcheck SC1007) --- packaging/linux/package.sh | 48 ++++++++++++++++++++++++++++++++++---- packaging/windows/sign.ps1 | 13 ++++++++--- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 9194cb075..47f743d4f 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -104,6 +104,20 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then + APPIMAGETOOL_VERSION=1.9.1 + # appimagetool otherwise embeds type2-runtime's unchecked `continuous` build in every AppImage. + RUNTIME_VERSION=20251108 + case "$ARCH" in + x86_64) + APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + ;; + aarch64) + APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 + ;; + *) APPIMAGETOOL_SHA256='' RUNTIME_SHA256='' ;; + esac APPDIR="$WORK/FilmCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" @@ -112,19 +126,43 @@ if has appimage; then cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" ln -s "$APP_ID.png" "$APPDIR/.DirIcon" + if [[ -z "$RUNTIME_SHA256" ]]; then + echo "no pinned AppImage runtime for $ARCH" >&2 + exit 2 + fi + RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$RUNTIME_VERSION-$ARCH" + if [[ ! -f "$RUNTIME" ]]; then + download="$RUNTIME.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$ARCH" + printf '%s %s\n' "$RUNTIME_SHA256" "$download" | sha256sum -c - + mv "$download" "$RUNTIME" + fi + printf '%s %s\n' "$RUNTIME_SHA256" "$RUNTIME" | sha256sum -c - + TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" if [[ -z "$TOOL" ]]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [[ ! -x "$TOOL" ]]; then - curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" + if [[ -z "$APPIMAGETOOL_SHA256" ]]; then + echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2 + exit 2 fi + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" + if [[ ! -x "$TOOL" ]]; then + download="$TOOL.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - + chmod 755 "$download" + mv "$download" "$TOOL" + fi + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - fi # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative, e.g. target/agent-). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" APPDIR="$(cd "$APPDIR" && pwd)" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")" # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. rm -f "$OUT.zsync" # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and @@ -136,7 +174,7 @@ if has appimage; then # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index 07aa8bef5..b083fc42f 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) { $script:SignTool = Find-SignTool $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'FilmCraft', '/du', 'https://github.com/storytold/filmcraft') -$tmp = Join-Path ([IO.Path]::GetTempPath()) "filmcraft-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) "filmcraft-sign-$([IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($haveCert) { @@ -73,8 +73,15 @@ try { Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. + $trustedSigningVersion = '1.0.95' + $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f' $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg" + Invoke-WebRequest -Uri $uri -OutFile $pkg + $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $trustedSigningSha256) { + throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 From f46a1a84368e23fd4a482fbfffae865714e6797e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:21:15 -0400 Subject: [PATCH 052/110] CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#2937) ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD and Windows ARM64 run nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69. --- .github/workflows/ci.yml | 72 +++++++---------------------------- .github/workflows/freebsd.yml | 12 +++--- 2 files changed, 21 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d156ff87f..5df1ae3a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,7 @@ -# 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 +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. on: push: branches: [main] @@ -14,61 +11,20 @@ on: permissions: contents: read +# A newer push to a PR branch cancels its older run; every push to main runs to the end. concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} 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" - 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 - uses: storytold/craft-common-actions/actions/install-apt-packages@main - with: - packages: >- - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev - libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers - libasound2-dev - - - name: Install Rust (stable + clippy, rustfmt, wasm32) - run: | - rustup toolchain install stable --profile minimal --component clippy,rustfmt \ - --target wasm32-unknown-unknown - rustup default stable - - # `xtask ci` runs the tests with nextest when it is installed. - - uses: taiki-e/install-action@a4d3f3f761dcd95cce1b72cb61cb6d1963438177 # v2.87.30 - with: - tool: cargo-nextest - - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Quality gate - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - run: cargo xtask ci + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Same craft-fonts commit as release.yml. + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + timeout-minutes: 120 + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libasound2-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0ad9259d9..6018ad42c 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -5,17 +5,19 @@ # golden-image tests need tools and a GPU the VM doesn't have.) name: FreeBSD -# The VM build takes an hour or more, so it runs nightly on main rather than on every push or -# dependency change: a drifted dependency still shows up within a day. PRs that change the -# FreeBSD packaging itself still run it. +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: - schedule: - - cron: "37 2 * * *" + push: + branches: [release] pull_request: paths: - .github/workflows/freebsd.yml - packaging/freebsd/** - packaging/env.sh + schedule: + - cron: "37 2 * * *" workflow_dispatch: concurrency: From 20f02b9aa1c7b06f8837e2ff8e469c43f198e7a4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:41:15 -0400 Subject: [PATCH 053/110] release: the git-sources gate moves into craft-common-actions (storytold/craft-common-actions#78) (#3014) The check is shared at release.yml@main now; keep only the reusable call. --- .github/workflows/release.yml | 40 ----------------------------------- 1 file changed, 40 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 546475f32..711a108d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,47 +18,7 @@ concurrency: cancel-in-progress: false jobs: - git-sources: - name: Git dependencies come from storytold - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Git dependencies come from storytold - run: | - set -euo pipefail - test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } - # Source replacement, patches and path overrides in cargo config never show up - # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. - # Cargo still reads the legacy .cargo/config. - python3 - <<'PY' - import os, sys, tomllib - for path in (".cargo/config", ".cargo/config.toml"): - if not os.path.exists(path): - continue - config = tomllib.load(open(path, "rb")) - replaced = [name for name, source in config.get("source", {}).items() - if isinstance(source, dict) and "replace-with" in source] - if replaced or "patch" in config or "paths" in config: - print(f"{path} replaces, patches or overrides a source") - sys.exit(1) - PY - # Fails when Cargo.lock is out of date with the manifests. - cargo metadata --locked --format-version 1 > /dev/null - python3 - <<'PY' - import sys, tomllib - lock = tomllib.load(open("Cargo.lock", "rb")) - bad = sorted({p["source"] for p in lock.get("package", []) - if p.get("source", "").startswith("git+") - and not p["source"].startswith("git+https://github.com/storytold/")}) - if bad: - print("Git dependencies outside github.com/storytold:", *bad, sep="\n") - sys.exit(1) - PY - release: - needs: git-sources # 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/release.yml@main with: From 93a845ce4144f3986c5da824a8b2d745a4a19e92 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:39:16 -0400 Subject: [PATCH 054/110] i18n: missing translations never fail; xtask ci prints the missing list (#3012) --- crates/ui-egui/README.md | 12 +- crates/ui-egui/src/i18n/mod.rs | 523 ++++-------------- crates/ui-egui/src/panels/shortcuts_dialog.rs | 14 +- crates/ui-egui/tests/it/graphics_notice_ui.rs | 8 +- crates/ui-egui/tests/it/scripted.rs | 5 +- docs/localization-parity.md | 12 +- xtask/src/main.rs | 22 +- 7 files changed, 163 insertions(+), 433 deletions(-) diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index c02b32ebf..75fbe94e1 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -38,9 +38,15 @@ translated label and its English source, including Unicode capitals. Project con and preference values retain their original values. Engine errors, CLI and MCP messages remain English. Ukrainian and Russian currently cover the core menus; they fall back to English elsewhere. -Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders, -literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual -checks use the control channel to switch language and capture the resulting panels. +A string a catalog lacks shows in English, so a missing translation never fails a test or a +build. `cargo xtask i18n-report` prints each language's missing strings (menus, `tl!` literals and +registry names) and its entries whose source the interface no longer has; `cargo xtask ci` prints +the same report in its log. + +Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders +and UI behaviour in other languages (switching, search, font coverage); `cargo xtask ci` runs the +workspace gates. Visual checks use the control channel to switch language and capture the resulting +panels. ## Source playback diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 1e2d09ba6..80f9dfe01 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -6,8 +6,8 @@ //! //! # Looking strings up //! - `tl!` with a string literal: that UI string in the language the UI is drawn in (see -//! [`set_current`]). Use it for every label, button, heading, tooltip and hint; the tests check -//! that each `tl!` literal has a Spanish translation. +//! [`set_current`]). Use it for every label, button, heading, tooltip and hint; `cargo xtask +//! i18n-report` lists the `tl!` literals each catalog lacks. //! - [`t`]: the same for a string that is not a literal (a name from a registry or a list). //! - [`tr_ctx`]: when one English word needs different translations (the Edit menu, the Edit button). //! - [`fmt`]: fill `{name}` placeholders after translating a template; translators may reorder them. @@ -340,21 +340,29 @@ pub fn korean_font_available() -> bool { mod tests { use super::*; + /// The catalog file of a language (none for English, the source language). + fn catalog_text(l: Language) -> Option<&'static str> { + match l { + Language::En => None, + Language::Ja => Some(include_str!("ja.tsv")), + Language::Es => Some(include_str!("es.tsv")), + Language::PtBr => Some(include_str!("pt-br.tsv")), + Language::Uk => Some(include_str!("uk.tsv")), + Language::ZhCn => Some(include_str!("zh-cn.tsv")), + Language::De => Some(include_str!("de.tsv")), + Language::Ru => Some(include_str!("ru.tsv")), + Language::Fr => Some(include_str!("fr.tsv")), + Language::Id => Some(include_str!("id.tsv")), + Language::It => Some(include_str!("it.tsv")), + Language::Ko => Some(include_str!("ko.tsv")), + } + } + #[test] fn catalogs_are_well_formed() { - for (code, text) in [ - ("es", include_str!("es.tsv")), - ("ja", include_str!("ja.tsv")), - ("pt-br", include_str!("pt-br.tsv")), - ("uk", include_str!("uk.tsv")), - ("zh-cn", include_str!("zh-cn.tsv")), - ("de", include_str!("de.tsv")), - ("ru", include_str!("ru.tsv")), - ("fr", include_str!("fr.tsv")), - ("id", include_str!("id.tsv")), - ("it", include_str!("it.tsv")), - ("ko", include_str!("ko.tsv")), - ] { + for l in Language::ALL { + let Some(text) = catalog_text(l) else { continue }; + let code = l.code(); let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); for (i, (ctx, en, tr)) in entries.iter().enumerate() { @@ -366,73 +374,54 @@ mod tests { assert_eq!(en.ends_with('…'), tr.ends_with('…'), "{code}: ellipsis differs in {en:?} → {tr:?}"); } } - assert_eq!(Language::Es.tr("File"), "Archivo"); - assert_eq!(Language::Ja.tr("File"), "ファイル"); - assert_eq!(Language::En.tr("File"), "File"); - assert_eq!(Language::Es.tr("mi video.mp4"), "mi video.mp4"); - assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); - assert_eq!(Language::parse("es"), Some(Language::Es)); - assert_eq!(Language::parse("xx"), None); - assert_eq!(Language::PtBr.tr("File"), "Arquivo"); - assert_eq!(Language::PtBr.tr("meu video.mp4"), "meu video.mp4"); - assert_eq!(Language::PtBr.name(), "Português (Brasil)"); - assert_eq!(Language::Uk.name(), "Українська"); - assert_eq!(Language::Uk.tr("File"), "Файл"); - assert_eq!(Language::Uk.tr("мій кліп.mp4"), "мій кліп.mp4"); - assert_eq!(Language::Uk.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::ZhCn.name(), "简体中文"); - assert_eq!(Language::ZhCn.tr("File"), "文件"); - assert_eq!(Language::ZhCn.tr("我的视频.mp4"), "我的视频.mp4"); - assert_eq!(Language::De.tr("File"), "Datei"); - assert_eq!(Language::De.tr("Save As…"), "Speichern unter…"); - assert_eq!(Language::De.tr("Größe ändern.mp4"), "Größe ändern.mp4"); - assert_eq!(Language::De.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::De.name(), "Deutsch"); - assert_eq!(Language::parse("de"), Some(Language::De)); - assert_eq!(Language::parse("de-DE"), None, "the preference stores the bare code"); - assert_eq!(Language::Ru.name(), "Русский"); - assert_eq!(Language::Ru.tr("File"), "Файл"); - assert_eq!(Language::Ru.tr("мой клип.mp4"), "мой клип.mp4"); - assert_eq!(Language::Ru.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::Fr.name(), "Français"); - assert_eq!(Language::Fr.tr("File"), "Fichier"); - assert_eq!(Language::Fr.tr("Save As…"), "Enregistrer sous…"); - assert_eq!(Language::Fr.tr("Montage d'été.mp4"), "Montage d'été.mp4"); - assert_eq!(Language::Fr.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::parse("fr"), Some(Language::Fr)); - assert_eq!(Language::parse("fr-FR"), None, "the preference stores the bare code"); - assert_eq!(Language::Id.name(), "Bahasa Indonesia"); - assert_eq!(Language::Id.tr("File"), "Berkas"); - assert_eq!(Language::Id.tr("Save As…"), "Simpan Sebagai…"); - assert_eq!(Language::Id.tr("Liburan keluarga.mp4"), "Liburan keluarga.mp4"); - assert_eq!(Language::Id.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::parse("id"), Some(Language::Id)); - assert_eq!(Language::parse("id-ID"), None, "the preference stores the bare code"); - assert_eq!(Language::It.name(), "Italiano"); - assert_eq!(Language::It.tr("File"), "File"); - assert_eq!(Language::It.tr("Save As…"), "Salva con nome…"); - assert_eq!(Language::It.tr("Vacanze d'estate.mp4"), "Vacanze d'estate.mp4"); - assert_eq!(Language::It.tr("An untranslated label"), "An untranslated label"); - assert_eq!(Language::parse("it"), Some(Language::It)); - assert_eq!(Language::parse("it-IT"), None, "the preference stores the bare code"); + } + + #[test] + fn unknown_strings_pass_through_and_languages_round_trip() { for l in Language::ALL { + for text in ["An untranslated label", "mi video.mp4", "日本語の文書.pdf", "Größe ändern.mp4", "Montage d'été.mp4"] { + assert_eq!(l.tr(text), text, "{}", l.code()); + } assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); assert_eq!(json, format!("\"{}\"", l.code())); assert_eq!(serde_json::from_str::(&json).unwrap(), l); } + assert_eq!(Language::En.tr("File"), "File"); + assert_eq!(Language::parse("xx"), None); + for code in ["de-DE", "fr-FR", "id-ID", "it-IT"] { + assert_eq!(Language::parse(code), None, "the preference stores the bare code"); + } + // language names are always shown in their own language + for (l, name) in [ + (Language::PtBr, "Português (Brasil)"), + (Language::Uk, "Українська"), + (Language::ZhCn, "简体中文"), + (Language::De, "Deutsch"), + (Language::Ru, "Русский"), + (Language::Fr, "Français"), + (Language::Id, "Bahasa Indonesia"), + (Language::It, "Italiano"), + (Language::Ko, "한국어"), + ] { + assert_eq!(l.name(), name); + } assert_eq!(fmt("{b} y {a}", &[("a", "1"), ("b", "2")]), "2 y 1"); assert_eq!(fmt("{a} {zz}", &[("a", "1")]), "1 {zz}"); } #[test] fn the_current_language_is_per_thread() { + // any entry the Spanish catalog has + let (entries, _) = catalog::parse_entries(include_str!("es.tsv")); + let Some((_, en, es)) = entries.into_iter().find(|(ctx, en, es)| ctx.is_empty() && en != es) else { return }; set_current(Language::Es); - assert_eq!((current(), t("File")), (Language::Es, "Archivo")); + assert_eq!((current(), t(&en)), (Language::Es, es.as_str())); // another thread (another test) still draws in English - assert_eq!(std::thread::spawn(|| t("File").to_string()).join().ok().as_deref(), Some("File")); + let source = en.clone(); + assert_eq!(std::thread::spawn(move || t(&source).to_string()).join().ok(), Some(en.clone())); set_current(Language::En); - assert_eq!(t("File"), "File"); + assert_eq!(t(&en), en); } #[test] @@ -442,170 +431,75 @@ mod tests { assert_eq!(fmt("{missing} y {unfinished", &[]), "{missing} y {unfinished"); } + /// A search for a language's label finds the item, and so does its English name. #[test] - fn searches_match_spanish_labels_and_english_names() { - set_current(Language::Es); - assert!(matches_query("Gaussian Blur", "desenfoque")); - assert!(matches_query("Gaussian Blur", "gaussian")); - assert!(matches_query("Selection Tool", &"SELECCIÓN".to_lowercase())); - assert!(!matches_query("Gaussian Blur", "selección")); + fn searches_match_translated_labels_and_english_names() { + for l in Language::ALL { + set_current(l); + let blur = l.tr("Gaussian Blur").to_lowercase(); + let selection = l.tr("Selection Tool").to_lowercase(); + assert!(matches_query("Gaussian Blur", &blur), "{}: {blur}", l.code()); + assert!(matches_query("Gaussian Blur", "gaussian"), "{}", l.code()); + assert!(matches_query("Selection Tool", &selection), "{}: {selection}", l.code()); + assert!(!matches_query("Gaussian Blur", &selection), "{}: {selection}", l.code()); + } set_current(Language::En); } + /// `tl!` and `tlf!` take plain string literals: the catalog scan (`tl_literals`) reads no other kind. #[test] - fn spanish_translates_every_menu_label() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && !Language::Es.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); - } - }; - for top in crate::menus::MENUS { - need(top); - } - for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); - } - } - missing.sort(); - assert!(missing.is_empty(), "untranslated menu labels: {missing:#?}"); - } - - /// The core menus every other catalog must cover: the entries of the menus-only Ukrainian - /// catalog, which `ukrainian_entries_cover_the_original_menu_catalog` keeps to menu labels (and - /// the native macOS Settings menu). Brazilian Portuguese played this role while it was - /// menus-only. - fn core_menu_labels() -> Vec { - let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); - entries.into_iter().map(|(_, en, _)| en).collect() - } - - /// Brazilian Portuguese covers the whole interface (the Spanish catalog's entries and the native - /// macOS Settings menu), but, like Japanese and French, the tests require only the menus, so - /// strings added later show in English until they are translated. Every entry must still have a - /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. - #[test] - fn portuguese_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("pt-br.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Portuguese entry without a Spanish one: {ctx:?} {en:?}"); - } - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && Language::Es.has(text) && !Language::PtBr.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); - } - }; - for top in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - need(top); - } - for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); - } - } - for en in core_menu_labels() { - need(&en); - } - missing.sort(); - assert!(missing.is_empty(), "untranslated Portuguese menu labels: {missing:#?}"); - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, pt) in [ - ("File", "Arquivo"), - ("Settings", "Preferências"), - ("Gaussian Blur", "Desfoque gaussiano"), - ("Audio Track Mixer", "Misturador de faixas de áudio"), - ("Keyboard Shortcuts", "Atalhos de teclado"), - ("Playhead", "Cursor de reprodução"), - ] { - assert_eq!(Language::PtBr.tr(en), pt); - } - set_current(Language::PtBr); - assert!(matches_query("Gaussian Blur", "desfoque")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// Every `tl!`/`tlf!` literal outside test modules has a Spanish translation. - #[test] - fn spanish_translates_every_tl_literal() { - let mut missing = Vec::new(); + fn tl_literals_are_plain_strings() { for (path, text) in sources() { - for lit in tl_literals(&text) { - if !Language::Es.has(&lit) && !missing.contains(&lit) { - missing.push(lit); - } - } assert!(!text.contains("tl!(r") && !text.contains("tlf!(r"), "{path}: use a plain string literal in tl! and tlf!"); } - missing.sort(); - assert!(missing.is_empty(), "untranslated tl! strings: {missing:#?}"); } - /// The Simplified Chinese catalog is complete: every menu label and every `tl!` literal. - #[test] - fn chinese_translates_every_menu_label_and_tl_literal() { + /// Every string the interface translates: menu labels and paths (the native macOS app menu + /// included), `tl!`/`tlf!` literals, and names translated through `i18n::t`. + fn interface_strings() -> Vec { let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && !Language::ZhCn.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); - } - }; - for top in crate::menus::MENUS { - need(top); + let mut out = Vec::new(); + for top in crate::menus::MENUS.into_iter().chain(["Audio"]).chain(crate::menus::NATIVE_APP_MENU) { + push(&mut out, top); } for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); - } + push(&mut out, &it.label); + it.path.iter().for_each(|p| push(&mut out, p)); } for (_, text) in sources() { - for lit in tl_literals(&text) { - need(&lit); - } + tl_literals(&text).iter().for_each(|lit| push(&mut out, lit)); } - missing.sort(); - assert!(missing.is_empty(), "untranslated Chinese strings: {missing:#?}"); + dynamic_names().iter().for_each(|n| push(&mut out, n)); + out } - /// The Japanese catalog is complete: every menu label, every `tl!` literal and every name the - /// interface translates through `i18n::t`. + /// The missing-translations report. It always passes: a string a catalog lacks shows in + /// English. `cargo xtask ci` runs it with `--nocapture` so the list shows in the gate log + /// (`cargo xtask i18n-report` runs it alone). Per language it lists the interface strings the + /// catalog lacks, then the catalog entries whose source the interface no longer has (renamed + /// or removed strings, or names this scan doesn't enumerate). #[test] - fn japanese_translates_every_menu_label_tl_literal_and_name() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let mut missing: Vec = Vec::new(); - let mut need = |text: &str| { - if translatable(text) && !Language::Ja.has(text) && !missing.iter().any(|m| m == text) { - missing.push(text.to_string()); + fn missing_translations_report() { + let strings = interface_strings(); + println!("Missing translations ({} interface strings; each shows in English):", strings.len()); + for l in Language::ALL { + let Some(text) = catalog_text(l) else { continue }; + let code = l.code(); + let mut missing: Vec<&str> = strings.iter().map(String::as_str).filter(|s| !l.has(s)).collect(); + missing.sort_unstable(); + println!("{code}: {} missing", missing.len()); + for s in missing { + println!(" {code}: {s:?}"); } - }; - for top in crate::menus::MENUS { - need(top); - } - for it in crate::menus::menu_items(&app) { - need(&it.label); - for p in &it.path { - need(p); + let (entries, _) = catalog::parse_entries(text); + let unused: Vec<_> = entries.iter().filter(|(_, en, _)| translatable(en) && !strings.contains(en)).collect(); + if !unused.is_empty() { + println!("{code}: {} entries without a source", unused.len()); + for (ctx, en, _) in unused { + println!(" {code}: {ctx:?} {en:?}"); + } } } - for (_, text) in sources() { - for lit in tl_literals(&text) { - need(&lit); - } - } - for name in dynamic_names() { - need(&name); - } - missing.sort(); - assert!(missing.is_empty(), "untranslated Japanese strings: {missing:#?}"); } /// Chinese switches and persists like the other languages when a Chinese face is available, @@ -873,13 +767,6 @@ mod tests { out } - #[test] - fn spanish_translates_dynamic_names() { - let mut missing: Vec = dynamic_names().into_iter().filter(|n| !Language::Es.has(n)).collect(); - missing.sort(); - assert!(missing.is_empty(), "untranslated names: {missing:#?}"); - } - #[test] fn tl_literals_are_found_and_unescaped() { let src = "a(tl!(\"Save\")); b(tl!(\"Say \\\"hi\\\"\\n\")); tl!(x); tlf!(\"{n} file(s)\", n);"; @@ -934,7 +821,7 @@ mod tests { // the menus are drawn in German, the language names in their own language let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Speichern unter…")); + assert_eq!(label("file.saveAs"), Some(Language::De.tr("Save As…"))); assert_eq!(label("app.language.spanish"), Some("Español")); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains("\"language\":\"de\""), "{saved}"); @@ -953,7 +840,7 @@ mod tests { } let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Enregistrer sous…")); + assert_eq!(label("file.saveAs"), Some(Language::Fr.tr("Save As…"))); assert_eq!(label("app.language.german"), Some("Deutsch")); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains("\"language\":\"fr\""), "{saved}"); @@ -970,7 +857,7 @@ mod tests { } let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Simpan Sebagai…")); + assert_eq!(label("file.saveAs"), Some(Language::Id.tr("Save As…"))); assert_eq!(label("app.language.french"), Some("Français")); assert_eq!(label("app.language.indonesian"), Some("Bahasa Indonesia")); let saved = serde_json::to_string(&app.ui).unwrap(); @@ -988,7 +875,7 @@ mod tests { } let items = crate::menus::menu_items(&app); let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); - assert_eq!(label("file.saveAs"), Some("Salva con nome…")); + assert_eq!(label("file.saveAs"), Some(Language::It.tr("Save As…"))); assert_eq!(label("app.language.indonesian"), Some("Bahasa Indonesia")); assert_eq!(label("app.language.italian"), Some("Italiano")); let saved = serde_json::to_string(&app.ui).unwrap(); @@ -1039,6 +926,9 @@ mod tests { assert_eq!(l(&[tag]), Language::It, "{tag}"); } assert_eq!(l(&["it-CH", "de-CH", "fr-CH", "en"]), Language::It); + for tag in ["ko", "ko-KR", "ko_KR.UTF-8"] { + assert_eq!(l(&[tag]), Language::Ko, "{tag}"); + } for none in [&["ita"][..], &["i"], &["-it"]] { assert_eq!(l(none), Language::En, "{none:?}"); } @@ -1089,199 +979,6 @@ mod tests { set_current(Language::En); } - #[test] - fn ukrainian_entries_cover_the_original_menu_catalog() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); - for (_, en, _) in entries { - assert!(known(&en) || en == "Settings", "not a menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert_ne!(Language::Uk.tr(en), en, "untranslated Ukrainian menu: {en}"); - } - } - - /// German covers the whole interface (every entry of the Spanish catalog, the native macOS app - /// menu included), but, like French, the tests require only the core menus and the native app - /// menu, so strings added later show in English until they are translated. Every entry must - /// still have a Spanish counterpart: a renamed or removed source would otherwise leave a dead - /// German entry. - #[test] - fn german_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("de.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && Language::Es.has(en), "German entry without a Spanish one: {ctx:?} {en:?}"); - } - for en in core_menu_labels() { - assert!(Language::De.has(&en), "missing German menu label: {en}"); - } - // "Clip" and "Audio" are the same word in German, so check for an entry, not a change - for en in crate::menus::MENUS.into_iter().chain(["Audio"]).chain(crate::menus::NATIVE_APP_MENU) { - assert!(Language::De.has(en), "untranslated German menu: {en}"); - } - assert_eq!(Language::De.tr("Settings"), "Voreinstellungen"); - assert_eq!(Language::De.tr("Quit FilmCraft"), "FilmCraft beenden"); - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, de) in [ - ("Gaussian Blur", "Gaußscher Weichzeichner"), - ("Audio Track Mixer", "Audiospur-Mischpult"), - ("Sequence", "Sequenz"), - ("Playhead", "Abspielposition"), - ("Keyboard Shortcuts", "Tastenkürzel"), - ("{n} min ago", "vor {n} Min."), - ] { - assert_eq!(Language::De.tr(en), de); - } - set_current(Language::De); - assert!(matches_query("Gaussian Blur", "weichzeichner")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// French covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu), but, like Japanese, the tests require only the core menus, so strings added - /// later show in English until they are translated. Every entry must still have a Spanish - /// counterpart: a renamed or removed source would otherwise leave a dead French entry. - #[test] - fn french_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("fr.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "French entry without a Spanish one: {ctx:?} {en:?}"); - } - for en in core_menu_labels() { - assert!(Language::Fr.has(&en), "missing French menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::Fr.has(en), "untranslated French menu: {en}"); - } - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, fr) in - [("Settings", "Préférences"), ("Gaussian Blur", "Flou gaussien"), ("Audio Track Mixer", "Mixeur de pistes audio"), ("Sequence", "Séquence")] - { - assert_eq!(Language::Fr.tr(en), fr); - } - set_current(Language::Fr); - assert!(matches_query("Gaussian Blur", "flou")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// Korean covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu); like French, the tests require the core menus, and every entry must still - /// have a Spanish counterpart so a renamed or removed source can't leave a dead Korean entry. - #[test] - fn korean_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("ko.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Korean entry without a Spanish one: {ctx:?} {en:?}"); - } - for en in core_menu_labels() { - assert!(Language::Ko.has(&en), "missing Korean menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::Ko.has(en), "untranslated Korean menu: {en}"); - } - for (en, ko) in [("File", "파일"), ("Edit", "편집"), ("Sequence", "시퀀스"), ("Settings", "설정")] { - assert_eq!(Language::Ko.tr(en), ko); - } - assert_eq!(Language::Ko.name(), "한국어"); - assert_eq!(Language::parse("ko"), Some(Language::Ko)); - for tag in ["ko", "ko-KR", "ko_KR.UTF-8"] { - assert_eq!(Language::from_locales(&[tag.to_string()]), Language::Ko, "{tag}"); - } - } - - #[test] - fn russian_entries_cover_the_original_menu_catalog() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - let (entries, _) = catalog::parse_entries(include_str!("ru.tsv")); - for (_, en, _) in entries { - assert!(known(&en) || en == "Settings", "not a menu label: {en}"); - } - for en in core_menu_labels() { - assert!(Language::Ru.has(&en), "missing Russian menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert_ne!(Language::Ru.tr(en), en, "untranslated Russian menu: {en}"); - } - } - - /// Indonesian covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu), but, like Japanese and French, the tests require only the core menus (the - /// menus-only Ukrainian catalog, the top-level menus and the native macOS Settings menu), so - /// strings added later show in English until they are translated. Every entry must still have a - /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. - #[test] - fn indonesian_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("id.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Indonesian entry without a Spanish one: {ctx:?} {en:?}"); - } - let (core, _) = catalog::parse_entries(include_str!("uk.tsv")); - for (_, en, _) in core { - assert!(Language::Id.has(&en), "missing Indonesian menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::Id.has(en), "untranslated Indonesian menu: {en}"); - } - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, id) in [ - ("Settings", "Preferensi"), - ("Gaussian Blur", "Buram Gaussian"), - ("Audio Track Mixer", "Mixer Trek Audio"), - ("Sequence", "Sekuens"), - ("Playhead", "Kepala Putar"), - ] { - assert_eq!(Language::Id.tr(en), id); - } - set_current(Language::Id); - assert!(matches_query("Gaussian Blur", "buram")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - - /// Italian covers the whole interface (the Spanish catalog's entries and the native macOS - /// Settings menu); like Indonesian, the tests require only the core menus, so strings added - /// later show in English until they are translated, and every entry must have a Spanish - /// counterpart. - #[test] - fn italian_covers_the_menus_and_has_no_dead_entries() { - let (entries, errors) = catalog::parse_entries(include_str!("it.tsv")); - assert!(errors.is_empty(), "{errors:?}"); - for (ctx, en, _) in &entries { - assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Italian entry without a Spanish one: {ctx:?} {en:?}"); - } - let (core, _) = catalog::parse_entries(include_str!("uk.tsv")); - for (_, en, _) in core { - assert!(Language::It.has(&en), "missing Italian menu label: {en}"); - } - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { - assert!(Language::It.has(en), "untranslated Italian menu: {en}"); - } - // beyond the menus: panels, effects and their parameters, settings, status messages - for (en, it) in [ - ("Settings", "Impostazioni"), - ("Gaussian Blur", "Sfocatura gaussiana"), - ("Audio Track Mixer", "Mixer tracce audio"), - ("Sequence", "Sequenza"), - ("Playhead", "Indicatore di riproduzione"), - ] { - assert_eq!(Language::It.tr(en), it); - } - set_current(Language::It); - assert!(matches_query("Gaussian Blur", "sfocatura")); - assert!(matches_query("Gaussian Blur", "gaussian")); - set_current(Language::En); - } - /// Every non-ASCII character of `text` renders with the bundled interface fonts, in every family. fn assert_bundled_fonts_cover(text: impl Iterator) { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs index db5fab592..62c50ddf3 100644 --- a/crates/ui-egui/src/panels/shortcuts_dialog.rs +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -822,13 +822,17 @@ mod localization_tests { use super::*; #[test] - fn command_search_accepts_spanish_labels_ids_and_keys() { - crate::i18n::set_current(crate::i18n::Language::Es); + fn command_search_accepts_translated_labels_ids_and_keys() { let row = json!({"id": "edit.undo", "label": "Undo", "category": "Edit", "shortcuts": [{"keys": "Cmd+Z", "display": "Ctrl+Z"}]}); - for query in ["deshacer", "undo", "edit.undo", "ctrl+z", "cmd+z"] { - assert!(matches_search(&row, query), "{query}"); + for l in crate::i18n::Language::ALL { + crate::i18n::set_current(l); + let undo = l.tr("Undo").to_lowercase(); + for query in [undo.as_str(), "undo", "edit.undo", "ctrl+z", "cmd+z"] { + assert!(matches_search(&row, query), "{}: {query}", l.code()); + } + let redo = l.tr("Redo").to_lowercase(); + assert!(!matches_search(&row, &redo), "{}: {redo}", l.code()); } - assert!(!matches_search(&row, "rehacer")); crate::i18n::set_current(crate::i18n::Language::En); } } diff --git a/crates/ui-egui/tests/it/graphics_notice_ui.rs b/crates/ui-egui/tests/it/graphics_notice_ui.rs index bd743ef17..e8805dc58 100644 --- a/crates/ui-egui/tests/it/graphics_notice_ui.rs +++ b/crates/ui-egui/tests/it/graphics_notice_ui.rs @@ -115,9 +115,11 @@ fn a_reset_that_cannot_be_saved_says_why_and_keeps_the_button() { fn the_notice_follows_the_interface_language() { let notice = GraphicsNotice::new("software rendering", Box::new(|| Ok(()))); let mut d = Driver::new(Some(notice)); - d.app().ui.language = filmcraft_ui_egui::i18n::Language::Es; + let es = filmcraft_ui_egui::i18n::Language::Es; + d.app().ui.language = es; d.frames(3); - assert_eq!(d.label("graphics.notice"), "Modo gráfico seguro: procesamiento por software"); - assert_eq!(d.label("graphics.notice.reset"), "Usar gráficos predeterminados"); + let title = filmcraft_ui_egui::i18n::fmt(es.tr("Safe graphics mode: {mode}"), &[("mode", es.tr("software rendering"))]); + assert_eq!(d.label("graphics.notice"), title); + assert_eq!(d.label("graphics.notice.reset"), es.tr("Use Default Graphics")); filmcraft_ui_egui::i18n::set_current(filmcraft_ui_egui::i18n::Language::En); } diff --git a/crates/ui-egui/tests/it/scripted.rs b/crates/ui-egui/tests/it/scripted.rs index 0fc6fbcc0..f9e2cc9ec 100644 --- a/crates/ui-egui/tests/it/scripted.rs +++ b/crates/ui-egui/tests/it/scripted.rs @@ -191,10 +191,11 @@ fn spanish_search_finds_effects_and_presets_with_stable_automation_ids() { let mut d = Driver::demo(); d.ok("ui.menu.invoke", json!({"id": "app.language.spanish"})); d.ok("ui.panel.show", json!({"panel": "Effects"})); - d.ok("ui.set", json!({"effectsSearch": "DESENFOQUE"})); + let es = filmcraft_ui_egui::i18n::Language::Es; + d.ok("ui.set", json!({"effectsSearch": es.tr("Gaussian Blur").to_uppercase()})); d.frames(3); assert!(d.element_ids("effects.item.").contains(&"effects.item.gaussian_blur".to_string())); - d.ok("ui.set", json!({"effectsSearch": "ENTRADA CON DESENFOQUE"})); + d.ok("ui.set", json!({"effectsSearch": es.tr("Blur In").to_uppercase()})); d.frames(3); assert!(d.element_ids("effects.preset.").contains(&"effects.preset.Blur In".to_string())); let clip = track_clips(&d.sequence(), 0)[0]["clip"].as_u64().unwrap(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d8773582d..53ab82423 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -48,18 +48,19 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | Arabic | ar | 0 (0%) | — | **no RTL interface layout**; fallback fonts and shaping in the interface, captions in reading order, other text in egui's left-to-right run order; titles broken (#395) | — | none | 40–70 h | | French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review | | Portuguese (Brazil) | pt-br | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#859) | review | -| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review | +| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review | | Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review | -| German | de | 3,590 (same entries as Spanish); must cover the core menus and the macOS app menu, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | menus yes (#630); the rest not recorded, welcome | partial | review | +| German | de | 3,590 (same entries as Spanish) | as Spanish | Latin (bundled fonts) | menus yes (#630); the rest not recorded, welcome | partial | review | | Korean | ko | 3,573 (same entries as Spanish, plus the macOS Settings item) | as Spanish | installed system Hangul font (Malgun Gothic, Apple SD Gothic Neo, Noto Sans CJK KR…); refused without one | not recorded; welcome | partial (#508) | review; Hangul in craft-fonts for the web | | Vietnamese | vi | 0 (0%) | — | Latin with stacked diacritics (font coverage to check) | — | none | 6–12 h + review | -| *Other shipped:* Italian | it | 3,573 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review | +| *Other shipped:* Italian | it | 3,573 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review | | *Other shipped:* Ukrainian | uk | 351 (10.0%) | menus only | Cyrillic (bundled) | not recorded | menus only | 5–9 h | UI strings are catalog entries (the non-comment lines of `src/i18n/.tsv`; English counts the source strings in the code), so the rows were measured at different times: at this change the -Spanish and German catalogs both have 3,590 entries, German every Spanish one. Strings added -later may stay English in German until translated; only the core menus are required. +Spanish and German catalogs both have 3,590 entries, German every Spanish one. A string a catalog +lacks shows in English; no test or build requires a translation. `cargo xtask i18n-report` (also +printed by `cargo xtask ci`) lists each language's missing strings. Premiere also ships Italian, which we now cover like Spanish, and Russian (6–10 h). @@ -73,6 +74,7 @@ Every language needs native-speaker review (human). | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Missing translations never fail a test or build (they show in English); `cargo xtask i18n-report`, also run by `cargo xtask ci`, lists them per language | | 2026-10-13 | minor | German (de) covers the whole interface: every entry of the Spanish catalog (3,590); the tests require the core menus and the macOS app menu and reject entries without a Spanish one | | 2026-10-11 | minor | German (de) ships the menus (#630): 371 of 3,498 strings (10.6%), native-reviewed; dimension now 2 menus-only, 5 none | | 2026-10-10 | major | Created from ROADMAP.md's "Interface localization" section; catalog coverage measured (es 3,496, ja 3,487, zh-cn 3,485, pt-br 348, uk 351 of 3,498); the 12-language table and script-support gaps added | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1e7c2dc7f..e3f883e0f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -16,8 +16,10 @@ //! lists, against a reference list (default: the local `plan/premiere/.json`), presence and //! placement reported separately (`parity.rs`, docs/gaps.md G7). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). +//! - `i18n-report`: print the interface strings each language's catalog lacks (they show in +//! English). It never fails. //! - `ci`: fmt check, clippy -D warnings, tests (`cargo nextest run` when installed) and doctests, -//! layers, assets, wasm. +//! the i18n report, layers, assets, wasm. mod ico; mod parity; @@ -458,7 +460,9 @@ fn ci() -> Result<(), String> { let cargo = env!("CARGO"); run(Command::new(cargo).args(["fmt", "--check"]))?; run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))?; - tests()?; + let tested = tests(); + i18n_report(); + tested?; layers()?; assets()?; wasm() @@ -479,6 +483,16 @@ fn tests() -> Result<(), String> { run(Command::new(cargo).args(["test", "--workspace", "--doc"])) } +/// The missing-translations report (`i18n::tests::missing_translations_report` in +/// `filmcraft-ui-egui`), printed to the log. A missing translation never fails a build, so +/// neither does this. +fn i18n_report() { + let args = ["test", "-p", "filmcraft-ui-egui", "--lib", "--", "--exact", "i18n::tests::missing_translations_report", "--nocapture"]; + if let Err(e) = run(Command::new(env!("CARGO")).args(args)) { + eprintln!("i18n report not printed: {e}"); + } +} + /// The headless playback benchmark (`crates/ui-egui/examples/bench_playback.rs`); extra /// arguments go to the benchmark (`cargo xtask bench-playback --scenario stack3 --res full`). fn bench_playback() -> Result<(), String> { @@ -507,6 +521,10 @@ fn main() -> ExitCode { "assets" => assets(), "fixtures" => fixtures(&std::env::args().skip(2).collect::>()), "ci" => ci(), + "i18n-report" => { + i18n_report(); + Ok(()) + } "bench" => bench(), "bench-playback" => bench_playback(), "web" => web(&std::env::args().skip(2).collect::>()), From e45ff5834600fb9737db514868eed822e4b8e8bd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 17:00:18 -0400 Subject: [PATCH 055/110] ci(sonar): don't cancel superseded runs on main, so coverage is published (#3026) --- .github/workflows/sonarcloud.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d..e78fe1ea3 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: From fa550ce2d07bdfee6b1d7b6835e8abc8c6913d00 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:51:38 -0400 Subject: [PATCH 056/110] Build: use craft-media for bitstream, isobmff and matroska (#2435) * Build: use craft-media for bitstream, isobmff and matroska filmcraft-bitstream, filmcraft-isobmff and filmcraft-matroska re-export craft_media::{bitstream, isobmff, matroska} from craft-libs. The packages and their public paths stay, so dependent crates build unchanged. The integration tests in crates/isobmff/tests and crates/matroska/tests run against the re-exports. * Build: pin craft-media at 9db732e craft-media spells the Matroska colour types in US English: Colour is Color and VideoInfo::colour is VideoInfo::color. The spec names element_ids::COLOUR, COLOUR_SPACE and VpcConfig::colour_primaries stay. * matroska: the oracle skips the duration of a track's last frame Ported from craft-media: the last audio or video frame of a track cut by -t is shorter than a codec frame. ffmpeg 8 writes a shortened BlockDuration and reports the codec frame duration; a remux by our writer stores DefaultDuration and ffprobe reports the shortened one. Subtitle cue durations are still compared. This fixes oracle::mjpeg_ac3 with ffmpeg 8. The HDR test is named hevc_hdr_color, as in craft-media. * matroska: laced_remux laces every audio track and checks timestamps craft-media laces only tracks with a DefaultDuration. The remux gives each audio track without one its mean packet spacing, so Vorbis, FLAC and PCM lace too, and compares every packet's timestamp with the source's. Ported from craft-media's oracle_matroska.rs. * codecs: the Matroska audio timing test laces its Xiph case craft-media laces only tracks with a DefaultDuration. The test's Matroska helper sets the AAC access unit's duration (1024 samples, rounded to the nanosecond), and the test checks that the Xiph-laced file has laced blocks and the unlaced one has none. * Build: repin craft-media to f97c84d27 (NAL length-size checks) craft-libs#245 is on storytold/craft-libs main, so the dependency points there. * Build: repin craft-media to f0ad6c1e1 (QuickTime v2 PCM entry limits) --- Cargo.lock | 14 +- Cargo.toml | 1 + crates/bitstream/Cargo.toml | 4 +- crates/bitstream/src/lib.rs | 495 +------ crates/codecs/src/audio_timing_tests.rs | 4 + crates/codecs/src/mkv.rs | 8 +- crates/isobmff/Cargo.toml | 3 +- crates/isobmff/README.md | 145 +- crates/isobmff/src/bytes.rs | 361 ----- crates/isobmff/src/codec.rs | 1724 ----------------------- crates/isobmff/src/demux.rs | 1205 ---------------- crates/isobmff/src/error.rs | 38 - crates/isobmff/src/lib.rs | 29 +- crates/isobmff/src/mux.rs | 963 ------------- crates/isobmff/src/source.rs | 123 -- crates/matroska/Cargo.toml | 1 + crates/matroska/README.md | 114 +- crates/matroska/src/codec.rs | 236 ---- crates/matroska/src/demux.rs | 1219 ---------------- crates/matroska/src/ebml.rs | 476 ------- crates/matroska/src/error.rs | 53 - crates/matroska/src/ids.rs | 214 --- crates/matroska/src/lib.rs | 38 +- crates/matroska/src/meta.rs | 608 -------- crates/matroska/src/mux.rs | 539 ------- crates/matroska/src/source.rs | 173 --- crates/matroska/src/synthetic_tests.rs | 210 --- crates/matroska/src/track.rs | 376 ----- crates/matroska/tests/oracle.rs | 20 +- 29 files changed, 48 insertions(+), 9346 deletions(-) delete mode 100644 crates/isobmff/src/bytes.rs delete mode 100644 crates/isobmff/src/codec.rs delete mode 100644 crates/isobmff/src/demux.rs delete mode 100644 crates/isobmff/src/error.rs delete mode 100644 crates/isobmff/src/mux.rs delete mode 100644 crates/isobmff/src/source.rs delete mode 100644 crates/matroska/src/codec.rs delete mode 100644 crates/matroska/src/demux.rs delete mode 100644 crates/matroska/src/ebml.rs delete mode 100644 crates/matroska/src/error.rs delete mode 100644 crates/matroska/src/ids.rs delete mode 100644 crates/matroska/src/meta.rs delete mode 100644 crates/matroska/src/mux.rs delete mode 100644 crates/matroska/src/source.rs delete mode 100644 crates/matroska/src/synthetic_tests.rs delete mode 100644 crates/matroska/src/track.rs diff --git a/Cargo.lock b/Cargo.lock index b5c05cd98..9ff01577b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1016,6 +1016,14 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-media" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1785,7 +1793,7 @@ dependencies = [ name = "filmcraft-bitstream" version = "0.12.0" dependencies = [ - "proptest", + "craft-media", ] [[package]] @@ -2101,17 +2109,17 @@ dependencies = [ name = "filmcraft-isobmff" version = "0.12.0" dependencies = [ - "filmcraft-bitstream", + "craft-media", "filmcraft-testkit", "proptest", "serde_json", - "thiserror 2.0.21", ] [[package]] name = "filmcraft-matroska" version = "0.12.0" dependencies = [ + "craft-media", "filmcraft-testkit", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 752532d2c..2801176d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ rayon = "1" proptest = "1" roxmltree = "0.21" craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", default-features = false } # ui # Shared frontend components; keep this dependency above the engine boundary. diff --git a/crates/bitstream/Cargo.toml b/crates/bitstream/Cargo.toml index 8510e348e..07e798ae4 100644 --- a/crates/bitstream/Cargo.toml +++ b/crates/bitstream/Cargo.toml @@ -9,6 +9,4 @@ rust-version.workspace = true workspace = true [dependencies] - -[dev-dependencies] -proptest = { workspace = true } +craft-media = { workspace = true, features = ["bitstream"] } diff --git a/crates/bitstream/src/lib.rs b/crates/bitstream/src/lib.rs index 5d2c8b64b..812b0d68c 100644 --- a/crates/bitstream/src/lib.rs +++ b/crates/bitstream/src/lib.rs @@ -1,496 +1,5 @@ -//! Bit-level reading and writing shared by FilmCraft's codec crates. -//! -//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers. -//! - [`BitWriter`]: the matching writer (for encoders and header rewriting). -//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes. -//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units. +//! Bit-level reading and writing: a re-export of [`craft_media::bitstream`] from craft-libs. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -use std::{cell::Cell, fmt}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BitError { - /// Tried to read past the end of the buffer. - Eof, - /// An Exp-Golomb code longer than 32 leading zeros. - InvalidExpGolomb, - /// An AVCC/HVCC NAL length prefix is not one of the supported widths. - InvalidNalLengthSize, -} - -impl fmt::Display for BitError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - BitError::Eof => f.write_str("unexpected end of bitstream"), - BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"), - BitError::InvalidNalLengthSize => f.write_str("NAL length prefix must be 1 to 4 bytes"), - } - } -} -impl std::error::Error for BitError {} - -pub type Result = std::result::Result; - -/// MSB-first bit reader over a byte slice. -#[derive(Clone)] -pub struct BitReader<'a> { - data: &'a [u8], - /// Position in bits. - pos: usize, - /// Cached position of the RBSP stop bit, computed only by [`Self::more_rbsp_data`]. - rbsp_stop: Cell>>, -} - -impl<'a> BitReader<'a> { - pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0, rbsp_stop: Cell::new(None) } - } - pub fn data(&self) -> &'a [u8] { - self.data - } - /// Bit position from the start. - pub fn position(&self) -> usize { - self.pos - } - pub fn bits_left(&self) -> usize { - (self.data.len() * 8).saturating_sub(self.pos) - } - pub fn is_byte_aligned(&self) -> bool { - self.pos.is_multiple_of(8) - } - pub fn byte_align(&mut self) { - self.pos = self.pos.div_ceil(8) * 8; - } - /// Byte offset of the current position (rounded down). - pub fn byte_pos(&self) -> usize { - self.pos / 8 - } - pub fn seek_bits(&mut self, pos: usize) { - self.pos = pos; - } - - /// Peek up to 32 bits without consuming (zero-padded past the end). - #[inline] - pub fn peek(&self, n: u32) -> u32 { - debug_assert!(n <= 32); - if n == 0 { - return 0; - } - let byte = self.pos / 8; - let shift = self.pos % 8; - let mut v: u64 = 0; - for i in 0..5 { - v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64; - } - // v holds 40 bits starting at `byte` - ((v << (24 + shift)) >> (64 - n)) as u32 - } - - #[inline] - pub fn read_bits(&mut self, n: u32) -> Result { - if n as usize > self.bits_left() { - return Err(BitError::Eof); - } - let v = self.peek(n); - self.pos += n as usize; - Ok(v) - } - - pub fn read_bits_u64(&mut self, n: u32) -> Result { - if n <= 32 { - return self.read_bits(n).map(u64::from); - } - let hi = self.read_bits(n - 32)? as u64; - let lo = self.read_bits(32)? as u64; - Ok((hi << 32) | lo) - } - - #[inline] - pub fn read_bit(&mut self) -> Result { - self.read_bits(1).map(|b| b == 1) - } - pub fn read_flag(&mut self) -> Result { - self.read_bit() - } - - pub fn skip(&mut self, n: usize) -> Result<()> { - if n > self.bits_left() { - return Err(BitError::Eof); - } - self.pos += n; - Ok(()) - } - - /// Raw Exp-Golomb codeNum, up to 32 leading zeros (so below 2^33). - fn read_code_num(&mut self) -> Result { - let mut zeros = 0u32; - while !self.read_bit()? { - zeros += 1; - if zeros > 32 { - return Err(BitError::InvalidExpGolomb); - } - } - if zeros == 0 { - return Ok(0); - } - let v = self.read_bits_u64(zeros)?; - Ok((1u64 << zeros) - 1 + v) - } - - /// Unsigned Exp-Golomb `ue(v)`. - pub fn read_ue(&mut self) -> Result { - u32::try_from(self.read_code_num()?).map_err(|_| BitError::InvalidExpGolomb) - } - - /// Signed Exp-Golomb `se(v)`. - pub fn read_se(&mut self) -> Result { - // codeNum 2^32 is -2^31, so the mapping is done before narrowing to 32 bits. - let k = self.read_code_num()? as i64; - let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) }; - i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb) - } - - /// Truncated Exp-Golomb `te(v)` with range `max`. - pub fn read_te(&mut self, max: u32) -> Result { - if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) } - } - - /// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits. - pub fn more_rbsp_data(&self) -> bool { - let total = self.data.len() * 8; - if self.pos >= total { - return false; - } - let last = self.rbsp_stop.get().unwrap_or_else(|| { - // The trailing stop bit is immutable for this reader. Caching it avoids searching a - // long zero suffix after every macroblock. - let stop = self.data.iter().rposition(|&b| b != 0).map(|i| i * 8 + 7 - self.data[i].trailing_zeros() as usize); - self.rbsp_stop.set(Some(stop)); - stop - }); - match last { - Some(stop) => self.pos < stop, - None => false, - } - } -} - -/// MSB-first bit writer. -#[derive(Default, Clone)] -pub struct BitWriter { - buf: Vec, - acc: u64, - nbits: u32, -} - -impl BitWriter { - pub fn new() -> Self { - Self::default() - } - pub fn write_bits(&mut self, value: u32, n: u32) { - debug_assert!(n <= 32); - if n == 0 { - return; - } - let v = (value as u64) & ((1u64 << n) - 1); - self.acc = (self.acc << n) | v; - self.nbits += n; - while self.nbits >= 8 { - self.nbits -= 8; - self.buf.push((self.acc >> self.nbits) as u8); - } - self.acc &= (1u64 << self.nbits) - 1; - } - pub fn write_bit(&mut self, b: bool) { - self.write_bits(b as u32, 1); - } - pub fn write_ue(&mut self, v: u32) { - self.write_code_num(u64::from(v)); - } - /// Exp-Golomb codeNum up to 2^32 (the code of `se(v)` -2^31). - fn write_code_num(&mut self, v: u64) { - let x = v + 1; - let len = 64 - x.leading_zeros(); - let zeros = len - 1; - for _ in 0..zeros { - self.write_bit(false); - } - if len > 32 { - self.write_bits((x >> 32) as u32, len - 32); - self.write_bits(x as u32, 32); - } else { - self.write_bits(x as u32, len); - } - } - pub fn write_se(&mut self, v: i32) { - let m = u64::from(v.unsigned_abs()) * 2; - self.write_code_num(if v > 0 { m - 1 } else { m }); - } - pub fn bit_len(&self) -> usize { - self.buf.len() * 8 + self.nbits as usize - } - pub fn is_byte_aligned(&self) -> bool { - self.nbits == 0 - } - /// Pad with zero bits to a byte boundary. - pub fn align_zero(&mut self) { - if self.nbits > 0 { - self.write_bits(0, 8 - self.nbits); - } - } - /// `rbsp_trailing_bits()`: a one bit then zeros to alignment. - pub fn rbsp_trailing(&mut self) { - self.write_bit(true); - self.align_zero(); - } - pub fn write_bytes(&mut self, bytes: &[u8]) { - if self.nbits == 0 { - self.buf.extend_from_slice(bytes); - } else { - for &b in bytes { - self.write_bits(b as u32, 8); - } - } - } - pub fn finish(mut self) -> Vec { - self.align_zero(); - self.buf - } - pub fn bytes(&self) -> &[u8] { - &self.buf - } -} - -/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload. -pub fn unescape_rbsp(data: &[u8]) -> Vec { - let mut out = Vec::with_capacity(data.len()); - let mut zeros = 0; - for &b in data { - if zeros >= 2 && b == 3 { - zeros = 0; - continue; - } - out.push(b); - zeros = if b == 0 { zeros + 1 } else { 0 }; - } - out -} - -/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears. -pub fn escape_rbsp(data: &[u8]) -> Vec { - let mut out = Vec::with_capacity(data.len() + data.len() / 64); - let mut zeros = 0; - for &b in data { - if zeros >= 2 && b <= 3 { - out.push(3); - zeros = 0; - } - out.push(b); - zeros = if b == 0 { zeros + 1 } else { 0 }; - } - out -} - -/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads). -pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> { - let mut starts = Vec::new(); - let mut i = 0; - while i + 3 <= data.len() { - if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 { - starts.push(i + 3); - i += 3; - } else { - i += 1; - } - } - let mut out = Vec::with_capacity(starts.len()); - for (k, &s) in starts.iter().enumerate() { - let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() }; - // trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits) - while e > s && data[e - 1] == 0 { - e -= 1; - } - if e > s { - out.push(&data[s..e]); - } - } - out -} - -/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1 to 4 bytes) into NAL units. -pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result> { - if !matches!(length_size, 1..=4) { - return Err(BitError::InvalidNalLengthSize); - } - let mut out = Vec::new(); - let mut i = 0; - while i < data.len() { - if i + length_size > data.len() { - return Err(BitError::Eof); - } - let mut len = 0usize; - for k in 0..length_size { - len = (len << 8) | data[i + k] as usize; - } - i += length_size; - if i + len > data.len() { - return Err(BitError::Eof); - } - out.push(&data[i..i + len]); - i += len; - } - Ok(out) -} - -/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG). -pub fn crc32(data: &[u8]) -> u32 { - let mut crc = 0xFFFF_FFFFu32; - for &b in data { - crc ^= b as u32; - for _ in 0..8 { - crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; - } - } - !crc -} - -#[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - - #[test] - fn reads_bits() { - let d = [0b1010_1100, 0b0101_0011]; - let mut r = BitReader::new(&d); - assert_eq!(r.read_bits(1).unwrap(), 1); - assert_eq!(r.read_bits(3).unwrap(), 0b010); - assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101); - assert_eq!(r.bits_left(), 4); - assert_eq!(r.read_bits(5), Err(BitError::Eof)); - } - - #[test] - fn exp_golomb_known() { - // 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4 - let mut w = BitWriter::new(); - for v in 0..5 { - w.write_ue(v); - } - let b = w.finish(); - let mut r = BitReader::new(&b); - for v in 0..5 { - assert_eq!(r.read_ue().unwrap(), v); - } - let mut w = BitWriter::new(); - for v in [0, 1, -1, 2, -2, 1000, -1000] { - w.write_se(v); - } - let b = w.finish(); - let mut r = BitReader::new(&b); - for v in [0, 1, -1, 2, -2, 1000, -1000] { - assert_eq!(r.read_se().unwrap(), v); - } - } - - #[test] - fn signed_exp_golomb_extremes_roundtrip() { - let vals = [0, 1, -1, i32::MAX, i32::MIN]; - let mut w = BitWriter::new(); - for v in vals { - w.write_se(v); - } - let b = w.finish(); - let mut r = BitReader::new(&b); - for v in vals { - assert_eq!(r.read_se(), Ok(v)); - } - } - - #[test] - fn signed_exp_golomb_reads_minimum_from_spec_bits() { - // 32 zero bits, 1, then the 32-bit suffix 1: codeNum = 2^32 -> -2^31. - let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80]; - assert_eq!(BitReader::new(&bytes).read_se(), Ok(i32::MIN)); - } - - #[test] - fn signed_exp_golomb_rejects_positive_overflow() { - // 32 zero bits, 1, then 32 zero bits: codeNum = 2^32 - 1 -> +2^31, not an i32. - let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0]; - assert_eq!(BitReader::new(&bytes).read_se(), Err(BitError::InvalidExpGolomb)); - } - - #[test] - fn unsigned_exp_golomb_keeps_u32_range() { - // codeNum = 2^32 is a valid se(v) code but does not fit ue(v)'s u32. - let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80]; - assert_eq!(BitReader::new(&bytes).read_ue(), Err(BitError::InvalidExpGolomb)); - } - - #[test] - fn emulation_prevention() { - let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3]; - let esc = escape_rbsp(&raw); - assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]); - assert_eq!(unescape_rbsp(&esc), raw); - } - - #[test] - fn annexb_split() { - let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9]; - let n = annexb_nals(&s); - assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]); - let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68]; - assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]); - } - - #[test] - fn length_prefixed_nals_rejects_invalid_prefix_sizes() { - for size in [0, 5, usize::MAX] { - assert!(length_prefixed_nals(&[0], size).is_err(), "size {size}"); - } - // `(lengthSizeMinusOne & 3) + 1` can be 3; such samples still split - assert_eq!(length_prefixed_nals(&[0, 0, 1, 0x65], 3).unwrap(), vec![&[0x65][..]]); - } - - #[test] - fn more_rbsp() { - let d = [0b1100_0000]; - let mut r = BitReader::new(&d); - assert_eq!(r.rbsp_stop.get(), None); - assert!(r.more_rbsp_data()); - assert_eq!(r.rbsp_stop.get(), Some(Some(1))); - r.read_bit().unwrap(); - assert!(!r.more_rbsp_data()); - } - - #[test] - fn crc() { - assert_eq!(crc32(b"123456789"), 0xCBF4_3926); - } - - proptest! { - #[test] - fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) { - let mut w = BitWriter::new(); - for &(v, n) in &vals { w.write_bits(v, n); } - for &u in &ues { w.write_ue(u); } - let b = w.finish(); - let mut r = BitReader::new(&b); - for &(v, n) in &vals { - let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 }; - prop_assert_eq!(r.read_bits(n).unwrap(), v & mask); - } - for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); } - } - - #[test] - fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) { - let e = escape_rbsp(&data); - prop_assert_eq!(unescape_rbsp(&e), data); - for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); } - } - } -} +pub use craft_media::bitstream::*; diff --git a/crates/codecs/src/audio_timing_tests.rs b/crates/codecs/src/audio_timing_tests.rs index 62f25cb10..e712f7052 100644 --- a/crates/codecs/src/audio_timing_tests.rs +++ b/crates/codecs/src/audio_timing_tests.rs @@ -50,6 +50,8 @@ fn mkv(asc: &[u8], rate: u32, units: &[Vec], lacing: LacingMode, gap: usize, let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC"); spec.codec_private = asc.to_vec(); spec.audio = Some((rate as f64, 2, None)); + // an access unit's duration, rounded to the nanosecond; without it the writer laces nothing + spec.default_duration_ns = Some((1024 * 1_000_000_000 + u64::from(rate) / 2) / u64::from(rate)); let opts = MuxOptions { lacing, ..MuxOptions::default() }; assert_eq!(opts.timestamp_scale, 1_000_000, "1 ms timestamps"); let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], opts).unwrap(); @@ -116,6 +118,8 @@ fn matroska_millisecond_stamps_play_back_to_back() { let frames = units.len().saturating_sub(1) * 1024; for lacing in [LacingMode::None, LacingMode::Xiph] { let bytes = mkv(&asc, rate, &units, lacing, usize::MAX, 0); + let laced = filmcraft_matroska::open(&bytes[..]).unwrap().tracks.iter().any(|t| t.samples.iter().any(|s| s.lace > 0)); + assert_eq!(laced, !matches!(lacing, LacingMode::None), "{rate} Hz, {lacing:?}: laced blocks"); let src = MkvSource::open("tone.mkv", bytes.clone()).unwrap(); let got = played(&src, rate, frames); let (n, first) = mismatches(&got, &want, 0); diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 2f51258a8..f2083b6d4 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -231,7 +231,7 @@ fn vp9_config(private: &[u8], v: Option<&filmcraft_matroska::VideoInfo>) -> VpcC } p += 2 + len; } - if let Some(col) = v.and_then(|v| v.colour.as_ref()) { + if let Some(col) = v.and_then(|v| v.color.as_ref()) { if let Some(t) = col.transfer_characteristics { c.transfer_characteristics = t as u8; } @@ -265,7 +265,7 @@ fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoI } /// HDR static metadata from the Matroska `Colour` element. -fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option { +fn hdr_metadata(c: &filmcraft_matroska::Color) -> Option { if c.mastering.is_none() && c.max_cll.is_none() && c.max_fall.is_none() { return None; } @@ -285,7 +285,7 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option) -> (ColorInfo, bool) { // H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here let code = |c: Option| c.unwrap_or(2); - let container = v.colour.as_ref().map(|col| { + let container = v.color.as_ref().map(|col| { let range = match col.range { Some(1) => Some(false), Some(2) => Some(true), @@ -404,7 +404,7 @@ impl MkvSource { color, has_alpha: v.alpha_mode != 0, bitrate, - hdr: v.colour.as_ref().and_then(hdr_metadata), + hdr: v.color.as_ref().and_then(hdr_metadata), } }); let audio_streams: Vec = atracks diff --git a/crates/isobmff/Cargo.toml b/crates/isobmff/Cargo.toml index c9b848fa0..b24ab1a1a 100644 --- a/crates/isobmff/Cargo.toml +++ b/crates/isobmff/Cargo.toml @@ -10,8 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] -filmcraft-bitstream = { workspace = true } -thiserror = { workspace = true } +craft-media = { workspace = true, features = ["isobmff"] } [dev-dependencies] filmcraft-testkit = { workspace = true } diff --git a/crates/isobmff/README.md b/crates/isobmff/README.md index 24cea2e43..39ba2ffd6 100644 --- a/crates/isobmff/README.md +++ b/crates/isobmff/README.md @@ -1,146 +1,3 @@ # filmcraft-isobmff -A clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) demuxer and muxer, -written from the public specifications. Layer L0: it depends only on `filmcraft-bitstream` and `thiserror`, -has no `unsafe`, and builds for `wasm32-unknown-unknown`. - -## Demuxing - -```rust -use filmcraft_isobmff::{open, TrackKind}; - -let file = std::fs::File::open("clip.mov")?; -let mp4 = open(&file)?; // any ByteSource: &[u8], Vec, Arc<[u8]>, File, ... -let v = mp4.track_of_kind(TrackKind::Video).unwrap(); -let track = &mp4.tracks[v]; -let i = track.sample_at_presentation_time(1000).unwrap(); // track timescale, edit-adjusted -let key = track.sync_sample_before(i); // decode from here -let bytes = mp4.read_sample(&file, v, key)?; // raw sample (AVCC/HVCC length-prefixed NALs, ...) -``` - -- `ByteSource` (`len`, `read_at(offset, buf)`) is the input abstraction. Implementations exist for `[u8]`, - `Vec`, `&T`, `Arc`, `Box`, and `std::fs::File` on non-wasm targets. -- `open` reads only top-level box headers, `ftyp`, `moov` and every `moof`. It never reads `mdat`. - The one exception is the first sample of a `tmcd` track, which it reads (4 bytes) to get the timecode - start frame. -- `Mp4File`: brands, `is_quicktime`, movie `timescale`/`duration`, `tracks`, `metadata`, `fragmented`, - `fragment_count`. -- `Track`: `id`, `kind` (Video/Audio/Timecode/Subtitle/Other), `handler`, `timescale`, `duration`, - `language`, `tkhd` fields (size, matrix, volume, enabled), `entries: Vec`, `samples`, - `edits`, `edit_offset`, `references` (`tref`), `sdtp`, `sample_groups` (`sbgp`+`sgpd`, raw), `pcm_chunked`. - Helpers: `codec()`, `video()`, `audio()`, `presentation_pts(i)`, `sample_at_pts(pts)`, - `sample_at_presentation_time(t)`, `sync_sample_before(i)`, `sync_samples()`. -- `Sample`: `offset`, `size`, `dts`, `pts` (dts + ctts), `duration`, `is_sync` (all samples are sync when - `stss` is absent), `description_index`. All times are raw media time in the track timescale. -- **Edit lists:** `edits` holds the `elst` entries unchanged. `edit_offset` is - `leading empty edits (rescaled to media timescale) − first non-empty edit's media_time`, so - `presentation time = pts + edit_offset`. This matches ffprobe's timestamps for the common cases - (B-frame delay, AAC priming, empty-edit offsets). Multi-segment edits, dwells and rate changes are exposed - raw for the caller to interpret. -- **Uncompressed PCM** (`lpcm`, `sowt`, `twos`, `in24`, `in32`, `fl32`, `fl64`, `raw `, `ipcm`, `fpcm`) is - merged to one `Sample` per chunk (`pcm_chunked = true`), with `duration` in frames. This avoids one - table entry per audio frame. - -### Parsed boxes -`ftyp`, `moov/mvhd`, `trak/tkhd`, `edts/elst` (v0/v1), `tref`, `mdia/mdhd` (v0/v1, ISO and Mac language -codes), `hdlr` (ISO and Pascal names), `minf/stbl`: `stsd`, `stts`, `ctts` (v0/v1, read as signed), `stss`, -`stsc`, `stsz`/`stz2` (4/8/16-bit), `stco`/`co64`, `sdtp`, `sbgp`/`sgpd`. Also `mvex/trex`, `moof/traf/tfhd/tfdt/trun` -(every `tfhd` base-offset mode and multiple `moof`s), and `udta` metadata (QuickTime `©xxx` atoms, -`meta/ilst`, and `meta/keys` (mdta)). `uuid`, `free`, `skip`, `wide`, `sidx` and `mfra` are skipped. -Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are supported. - -### Sample entries → `CodecConfig` -| Format | Config | -|---|---| -| `avc1`/`avc3` + `avcC` | `Avc(AvcConfig)`: profile, level, length size, SPS/PPS, extension bytes | -| `hvc1`/`hev1` + `hvcC` | `Hevc(HevcConfig)`: all header fields plus NAL arrays (`vps()/sps()/pps()`) | -| `av01` + `av1C` | `Av1(Av1Config)` | -| `vp09` + `vpcC` | `Vp9(VpcConfig)` | -| `apch apcn apcs apco ap4h ap4x` | `ProRes { fourcc }` | -| `jpeg mjpa mjpb` | `Jpeg { fourcc }` | -| `AVdn AVdh` | `Dnx { fourcc }` | -| `mp4a` + `esds` (in `wave` too) | `Aac(AacConfig)`: ASC bytes + decoded object type/rate/channels; `Mp3` for OTI 0x69/0x6B | -| `.mp3`/`.mp2` | `Mp3`: MPEG-1/2 audio of any layer (each frame header names it) | -| PCM fourccs | `Pcm(PcmConfig)`: bits, float, endianness (`enda`, lpcm flags, `pcmC`), channels, rate | -| `alac` | `Alac { cookie }` | -| `Opus` + `dOps` | `Opus(OpusConfig)` | -| `fLaC` + `dfLa` | `Flac(FlacConfig)` (STREAMINFO decoded) | -| `ac-3`/`ec-3` | `Ac3 { dac3 }` / `Eac3 { dec3 }` | -| `tmcd` | `Timecode(TimecodeConfig)`: flags (drop frame), timescale, frame duration, fps, start frame, reel name; `format_frame()` | -| anything else | `Unknown { fourcc, raw }` | - -Video entries also parse `colr` (`nclx`/`nclc`/ICC), `pasp`, `clap`, `fiel`, `gama` and `btrt` into -`VideoParams`. The MP4 source in `filmcraft-codecs` crops every frame to `clap`'s clean aperture -before the track's display rotation, and reports that size. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`. - -### Robustness -The parser does not panic on malformed input; every error comes back as an `Error`. Table counts are -checked against the bytes in their box before anything is allocated. A constant-size `stsz` count is -clamped to what fits in the file. `trun` counts are checked, and each track is capped at 2²⁶ samples. -`moov`/`moof` are capped at 1 GiB. Compressed movie headers (`cmov`) are rejected with -`Error::Unsupported`. - -## Muxing - -```rust -use filmcraft_isobmff::*; -let mut w = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4))?; -let v = w.add_track(TrackConfig::new(SampleEntry::avc(avcc, 1920, 1080), 90_000))?; -let mut acfg = TrackConfig::new(SampleEntry::aac(asc, 2, 48_000), 48_000); -acfg.media_start = Some(1024); // AAC encoder delay → edit list -let a = w.add_track(acfg)?; -w.write_sample(v, WriteSample { data: &au, duration: 3000, composition_offset: 6000, is_sync: true })?; -// ... interleave samples in decode order ... -let bytes = w.finish_faststart()?.into_inner(); // or finish() for moov-at-end -``` - -- `Mp4Writer` streams samples into a single `mdat`. `finish()` appends `moov`. - `finish_faststart()` (needs `W: Read + Write + Seek`) moves the media data forward in place and writes - `moov` before it. If `mdat` goes past 4 GiB the header becomes a large-size header, and chunk offsets - switch to `co64` when needed. -- Brands: `Brand::Mp4` writes `isom` with `isom iso2 avc1 mp41 mp42`. `Brand::Mov` writes `qt `, uses - QuickTime `hdlr`/Pascal names, and sets `wide` as the placeholder box. -- Tables written: `stts`, `ctts` (v1 when an offset is negative), `stss` (omitted when every sample is - sync), `stsc`, `stsz` (constant or table), `stco`/`co64`. Consecutive samples of one track share a chunk. -- Sample entries come from `SampleEntry` (`avc`, `hevc`, `prores`, `jpeg`, `aac`, `pcm`, or any demuxed - entry). Remuxing copies codec config, `colr`, `pasp`, `clap`, `fiel`, `gama` and `btrt`. - PCM is written as `sowt`/`twos` (16-bit) or `raw ` (8-bit) or sound description v2 `lpcm` in MOV, and as - `ipcm`/`fpcm` + `pcmC` in MP4. A PCM `write_sample` call can hold any whole number of frames, and the - track timescale must equal the sample rate. -- Edit lists: set `TrackConfig::edits` explicitly, or use `media_start` to get a single edit. -- Timecode: `add_timecode_track(video, TimecodeConfig, start_frame)` adds a `tmcd` track (with `gmhd`) - and a `tref/tmcd` link from the video track. -- `WriterOptions::metadata` writes `©xxx` text atoms to `udta`. -- `FragmentedWriter` writes fMP4. The init segment is `ftyp` + `moov` with `mvex/trex`. Each - `flush_fragment()` then writes `moof` (`mfhd`, `traf` with default-base-is-moof `tfhd`, `tfdt` v1, - `trun` with per-sample duration, size, flags and composition offset) followed by `mdat`. - -## Tests -- Unit tests cover the box reader and hand-built `stbl` edge cases (stz2, co64, ctts v1, empty edits, - 64-bit and size-0 boxes, uuid/wide/skip), codec config round-trips, and allocation guards. -- Property tests (`tests/it/roundtrip.rs`) write random multi-track files, both progressive (MP4/MOV, - faststart, PCM, edits) and fragmented, then read them back and compare sample tables and bytes. -- ffprobe oracle tests (`tests/it/oracle_demux.rs`) run on ffmpeg-generated fixtures in - `target/fixtures/isobmff/`: H.264 with B-frames (MP4 and MOV), HEVC, ProRes 422/4444, MJPEG, DNxHR, PCM - s16le/s16be/s24le/f32le, AAC, ALAC, FLAC, Opus, AC-3, VP9, AV1, fragmented (with and without - default-base-is-moof), faststart, an edit-list cut, `tmcd` at 29.97 DF and 23.976, and colr/pasp. They - compare packet offsets, sizes, pts/dts, durations and key flags, plus stream parameters. Tests skip - with a message when ffmpeg or an encoder is missing. -- Muxer oracle tests (`tests/it/oracle_mux.rs`) remux fixtures with our writers. They check that - `ffmpeg -v error -f null` reports nothing, that ffprobe packets and stream parameters match the source, - and that timecode, the AAC priming edit, ipcm and fMP4 output are correct. -- Mutation fuzzing (`tests/it/fuzz.rs`) uses a deterministic seed to mutate and truncate synthetic and - ffmpeg files, and checks that nothing panics. - -## Limitations / not yet supported -- `presentation time = pts + edit_offset` only applies the leading empty edits and the first media - edit. Multi-segment edit lists, dwell edits and non-unity rates are exposed raw. ffmpeg handles an edit - that starts mid-frame differently: it snaps to the frame. -- Other data references (external `alis`/`url ` files), encrypted entries (`encv`/`enca`, `sinf`, - `senc`), `cmov`, and QuickTime reference movies are not supported. -- `sidx`/`mfra` are ignored, because the demuxer scans every top-level box. `stsd` entries with a - QuickTime colour table are not decoded. -- Subtitle and text tracks (`tx3g`, `c608`, `wvtt`) and `mp4v` show up as `Unknown`. -- Muxer: one `mdat`, no chunk-size or duration limits (the caller controls interleaving), no `sdtp`, - `sgpd`/`sbgp` or `cslg` output, ISO-style `meta/ilst` metadata is not written, and the fragmented - writer has no PCM, `sidx` or `mfra`. +A re-export of `craft_media::isobmff`. Documentation: https://github.com/storytold/craft-libs/blob/main/crates/media/README.md diff --git a/crates/isobmff/src/bytes.rs b/crates/isobmff/src/bytes.rs deleted file mode 100644 index 97f92690b..000000000 --- a/crates/isobmff/src/bytes.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! Low-level helpers: four-character codes, a bounds-checked big-endian cursor, box iteration -//! over in-memory payloads, and a box builder for the muxers. - -use crate::error::{Error, Result}; -use std::fmt; - -/// A four-character code (box type, sample entry format, brand, handler type…). -#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] -pub struct FourCc(pub [u8; 4]); - -impl FourCc { - pub const fn new(b: &[u8; 4]) -> Self { - FourCc(*b) - } - pub fn as_u32(self) -> u32 { - u32::from_be_bytes(self.0) - } - /// Lossy string form; `©` (0xA9) is mapped to U+00A9, other non-printables to `?`. - pub fn to_string_lossy(self) -> String { - self.0 - .iter() - .map(|&b| match b { - 0xA9 => '©', - 0x20..=0x7E => b as char, - _ => '?', - }) - .collect() - } -} - -impl fmt::Display for FourCc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.to_string_lossy()) - } -} -impl fmt::Debug for FourCc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "FourCc({:?})", self.to_string_lossy()) - } -} -impl From<&[u8; 4]> for FourCc { - fn from(b: &[u8; 4]) -> Self { - FourCc(*b) - } -} -impl PartialEq<[u8; 4]> for FourCc { - fn eq(&self, other: &[u8; 4]) -> bool { - &self.0 == other - } -} -impl PartialEq<&[u8; 4]> for FourCc { - fn eq(&self, other: &&[u8; 4]) -> bool { - &self.0 == *other - } -} -impl PartialEq<&&[u8; 4]> for FourCc { - fn eq(&self, other: &&&[u8; 4]) -> bool { - &self.0 == **other - } -} - -/// Bounds-checked big-endian cursor over a byte slice. -#[derive(Clone)] -pub(crate) struct Cur<'a> { - pub data: &'a [u8], - pub pos: usize, - what: &'static str, -} - -impl<'a> Cur<'a> { - pub fn new(data: &'a [u8], what: &'static str) -> Self { - Cur { data, pos: 0, what } - } - pub fn remaining(&self) -> usize { - self.data.len().saturating_sub(self.pos) - } - pub fn rest(&self) -> &'a [u8] { - self.data.get(self.pos..).unwrap_or(&[]) - } - pub fn bytes(&mut self, n: usize) -> Result<&'a [u8]> { - let end = self.pos.checked_add(n).ok_or(Error::Truncated(self.what))?; - let s = self.data.get(self.pos..end).ok_or(Error::Truncated(self.what))?; - self.pos = end; - Ok(s) - } - pub fn skip(&mut self, n: usize) -> Result<()> { - self.bytes(n).map(|_| ()) - } - pub fn array(&mut self) -> Result<[u8; N]> { - let s = self.bytes(N)?; - let mut a = [0u8; N]; - a.copy_from_slice(s); - Ok(a) - } - pub fn u8(&mut self) -> Result { - Ok(self.array::<1>()?[0]) - } - pub fn u16(&mut self) -> Result { - Ok(u16::from_be_bytes(self.array()?)) - } - pub fn i16(&mut self) -> Result { - Ok(i16::from_be_bytes(self.array()?)) - } - pub fn u24(&mut self) -> Result { - let a = self.array::<3>()?; - Ok(((a[0] as u32) << 16) | ((a[1] as u32) << 8) | a[2] as u32) - } - pub fn u32(&mut self) -> Result { - Ok(u32::from_be_bytes(self.array()?)) - } - pub fn i32(&mut self) -> Result { - Ok(i32::from_be_bytes(self.array()?)) - } - pub fn u64(&mut self) -> Result { - Ok(u64::from_be_bytes(self.array()?)) - } - pub fn i64(&mut self) -> Result { - Ok(i64::from_be_bytes(self.array()?)) - } - pub fn f64(&mut self) -> Result { - Ok(f64::from_be_bytes(self.array()?)) - } - pub fn fourcc(&mut self) -> Result { - Ok(FourCc(self.array()?)) - } - /// Full-box header: (version, flags). - pub fn full_header(&mut self) -> Result<(u8, u32)> { - let v = self.u32()?; - Ok(((v >> 24) as u8, v & 0x00FF_FFFF)) - } - /// Read a count and verify that `count * entry_size` bytes remain (allocation guard). - pub fn count(&mut self, entry_size: usize) -> Result { - let n = self.u32()? as usize; - self.check_count(n, entry_size)?; - Ok(n) - } - pub fn check_count(&self, n: usize, entry_size: usize) -> Result<()> { - match n.checked_mul(entry_size) { - Some(b) if b <= self.remaining() => Ok(()), - _ => Err(Error::Truncated(self.what)), - } - } -} - -/// Header of one box as found in a byte stream. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct BoxHeader { - pub kind: FourCc, - /// Header length including largesize and uuid extended type. - pub header_len: u64, - /// Total box size including header; `None` means "extends to end of the enclosing container". - pub size: Option, - pub uuid: Option<[u8; 16]>, -} - -/// Parse a box header from `buf` (needs up to 32 bytes; fewer if available). -pub(crate) fn parse_box_header(buf: &[u8]) -> Result { - let mut c = Cur::new(buf, "box header"); - let size32 = c.u32()?; - let kind = c.fourcc()?; - let mut header_len = 8u64; - let size = match size32 { - 0 => None, - 1 => { - header_len += 8; - let s = c.u64()?; - Some(s) - } - s => Some(s as u64), - }; - let uuid = if kind == b"uuid" { - header_len += 16; - Some(c.array::<16>()?) - } else { - None - }; - if let Some(s) = size - && s < header_len - { - return Err(Error::Invalid(format!("box '{kind}' size {s} smaller than its header"))); - } - Ok(BoxHeader { kind, header_len, size, uuid }) -} - -/// One child box inside an in-memory payload. -#[derive(Clone, Copy, Debug)] -pub(crate) struct RawBox<'a> { - pub kind: FourCc, - pub payload: &'a [u8], - #[allow(dead_code)] - pub uuid: Option<[u8; 16]>, -} - -/// Iterator over the child boxes of a payload. Yields an error for a malformed box and then stops. -pub(crate) struct Boxes<'a> { - data: &'a [u8], - pos: usize, - done: bool, -} - -pub(crate) fn boxes(data: &[u8]) -> Boxes<'_> { - Boxes { data, pos: 0, done: false } -} - -impl<'a> Iterator for Boxes<'a> { - type Item = Result>; - fn next(&mut self) -> Option { - if self.done { - return None; - } - let rest = &self.data[self.pos..]; - if rest.is_empty() { - self.done = true; - return None; - } - // QuickTime allows a 32-bit zero terminator at the end of some containers. - if rest.len() < 8 { - self.done = true; - if rest.iter().all(|&b| b == 0) { - return None; - } - return Some(Err(Error::Truncated("box header"))); - } - let h = match parse_box_header(rest) { - Ok(h) => h, - Err(e) => { - self.done = true; - return Some(Err(e)); - } - }; - let size = h.size.unwrap_or(rest.len() as u64); - if size > rest.len() as u64 { - self.done = true; - return Some(Err(Error::Invalid(format!("box '{}' size {} exceeds its container ({} bytes left)", h.kind, size, rest.len())))); - } - let size = size as usize; - let payload = &rest[h.header_len as usize..size]; - self.pos += size; - Some(Ok(RawBox { kind: h.kind, payload, uuid: h.uuid })) - } -} - -/// Find the first child of the given type (malformed trailing children are ignored). -pub(crate) fn find<'a>(data: &'a [u8], kind: &[u8; 4]) -> Option<&'a [u8]> { - boxes(data).map_while(|b| b.ok()).find(|b| b.kind == kind).map(|b| b.payload) -} - -/// Growable buffer for building boxes. -#[derive(Default, Clone, Debug)] -pub(crate) struct BoxBuf { - pub buf: Vec, -} - -impl BoxBuf { - pub fn new() -> Self { - Self::default() - } - /// Begin a box; returns a marker to pass to [`end`](Self::end). - pub fn start(&mut self, kind: &[u8; 4]) -> usize { - let at = self.buf.len(); - self.buf.extend_from_slice(&[0, 0, 0, 0]); - self.buf.extend_from_slice(kind); - at - } - pub fn start_full(&mut self, kind: &[u8; 4], version: u8, flags: u32) -> usize { - let at = self.start(kind); - self.u32(((version as u32) << 24) | (flags & 0xFF_FFFF)); - at - } - pub fn end(&mut self, at: usize) { - let size = (self.buf.len() - at) as u32; - self.buf[at..at + 4].copy_from_slice(&size.to_be_bytes()); - } - /// Append a complete box with the given payload. - pub fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) { - let at = self.start(kind); - self.bytes(payload); - self.end(at); - } - pub fn u8(&mut self, v: u8) { - self.buf.push(v); - } - pub fn u16(&mut self, v: u16) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i16(&mut self, v: i16) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn u32(&mut self, v: u32) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i32(&mut self, v: i32) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn u64(&mut self, v: u64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i64(&mut self, v: i64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn f64(&mut self, v: f64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn bytes(&mut self, b: &[u8]) { - self.buf.extend_from_slice(b); - } - pub fn zeros(&mut self, n: usize) { - self.buf.resize(self.buf.len() + n, 0); - } - pub fn len(&self) -> usize { - self.buf.len() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn header_forms() { - let h = parse_box_header(&[0, 0, 0, 16, b'f', b'r', b'e', b'e']).unwrap(); - assert_eq!((h.kind, h.header_len, h.size), (FourCc(*b"free"), 8, Some(16))); - let h = parse_box_header(&[0, 0, 0, 0, b'm', b'd', b'a', b't']).unwrap(); - assert_eq!(h.size, None); - let mut v = vec![0, 0, 0, 1, b'm', b'd', b'a', b't']; - v.extend_from_slice(&100u64.to_be_bytes()); - let h = parse_box_header(&v).unwrap(); - assert_eq!((h.header_len, h.size), (16, Some(100))); - let mut v = vec![0, 0, 0, 30, b'u', b'u', b'i', b'd']; - v.extend_from_slice(&[7u8; 16]); - let h = parse_box_header(&v).unwrap(); - assert_eq!((h.header_len, h.uuid), (24, Some([7u8; 16]))); - assert!(parse_box_header(&[0, 0, 0, 4, b'f', b'r', b'e', b'e']).is_err()); - assert!(parse_box_header(&[0, 0, 0]).is_err()); - } - - #[test] - fn iterate_children() { - let mut b = BoxBuf::new(); - b.leaf(b"aaaa", &[1, 2]); - let m = b.start(b"bbbb"); - b.leaf(b"cccc", &[]); - b.end(m); - b.u32(0); // QT terminator - let kids: Vec<_> = boxes(&b.buf).collect::>>().unwrap(); - assert_eq!(kids.len(), 2); - assert_eq!(kids[0].payload, &[1, 2]); - assert_eq!(find(&b.buf, b"bbbb").map(|p| p.len()), Some(8)); - // Oversized child → error, no panic. - let bad = [0u8, 0, 0, 99, b'x', b'x', b'x', b'x', 0]; - assert!(boxes(&bad).next().unwrap().is_err()); - } - - #[test] - fn cursor_bounds() { - let mut c = Cur::new(&[0, 0, 0, 5, 1], "t"); - assert!(c.count(1).is_err()); - let mut c = Cur::new(&[0xFF, 0xFF, 0xFF, 0xFF], "t"); - assert!(c.count(usize::MAX).is_err()); - } -} diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs deleted file mode 100644 index 1fbcff9ce..000000000 --- a/crates/isobmff/src/codec.rs +++ /dev/null @@ -1,1724 +0,0 @@ -//! Sample entries (`stsd` children) and codec configuration records: parsing and serialization. - -use crate::bytes::{BoxBuf, Cur, FourCc, boxes}; -use crate::error::{Error, Result}; -use filmcraft_bitstream::BitReader; - -/// One sample description (an entry of `stsd`). -#[derive(Clone, Debug, PartialEq)] -pub struct SampleEntry { - /// Sample entry format (`avc1`, `mp4a`, `apch`, …). - pub format: FourCc, - pub data_reference_index: u16, - pub codec: CodecConfig, - /// Present for visual sample entries. - pub video: Option, - /// Present for audio sample entries. - pub audio: Option, - /// `btrt` box. - pub bitrate: Option, -} - -/// Fields common to all visual sample entries plus the optional descriptive boxes. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct VideoParams { - pub width: u16, - pub height: u16, - /// Horizontal/vertical resolution in pixels per inch (16.16 fixed point; 72 dpi = 0x0048_0000). - pub horiz_resolution: u32, - pub vert_resolution: u32, - pub frame_count: u16, - pub compressor_name: String, - pub depth: u16, - /// QuickTime `vendor` / version fields (zero in ISO files). - pub vendor: FourCc, - pub temporal_quality: u32, - pub spatial_quality: u32, - pub color: Option, - /// `pasp`: (hSpacing, vSpacing). - pub pixel_aspect: Option<(u32, u32)>, - pub clean_aperture: Option, - pub field_info: Option, - /// QuickTime `gama` (16.16 fixed point). - pub gamma: Option, - /// `mdcv`: mastering display colour volume (SMPTE ST 2086). - pub mastering_display: Option, - /// `clli`: content light level (MaxCLL, MaxFALL in cd/m²; 0 = unknown). - pub content_light: Option<(u16, u16)>, -} - -/// SMPTE ST 2086 mastering display metadata, in the units of the `mdcv` box and the H.264/HEVC -/// SEI: chromaticities in 0.00002 steps (order G, B, R as the SEI), luminance in 0.0001 cd/m². -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MasteringDisplay { - pub primaries: [(u16, u16); 3], - pub white_point: (u16, u16), - pub max_luminance: u32, - pub min_luminance: u32, -} - -impl MasteringDisplay { - /// BT.2020 primaries, D65, with the given peak and black in cd/m². - pub fn bt2020(max_nits: f64, min_nits: f64) -> Self { - let q = |v: f64| (v / 0.00002).round() as u16; - MasteringDisplay { - primaries: [(q(0.170), q(0.797)), (q(0.131), q(0.046)), (q(0.708), q(0.292))], - white_point: (q(0.3127), q(0.3290)), - max_luminance: (max_nits * 10_000.0).round() as u32, - min_luminance: (min_nits * 10_000.0).round() as u32, - } - } - pub fn max_nits(&self) -> f64 { - self.max_luminance as f64 / 10_000.0 - } - /// The 24-byte payload shared by `mdcv` and the SEI message. - pub fn to_bytes(&self) -> Vec { - let mut v = Vec::with_capacity(24); - for (x, y) in self.primaries { - v.extend_from_slice(&x.to_be_bytes()); - v.extend_from_slice(&y.to_be_bytes()); - } - v.extend_from_slice(&self.white_point.0.to_be_bytes()); - v.extend_from_slice(&self.white_point.1.to_be_bytes()); - v.extend_from_slice(&self.max_luminance.to_be_bytes()); - v.extend_from_slice(&self.min_luminance.to_be_bytes()); - v - } - pub fn parse(p: &[u8]) -> Option { - if p.len() < 24 { - return None; - } - let u16_at = |i: usize| u16::from_be_bytes([p[i], p[i + 1]]); - let u32_at = |i: usize| u32::from_be_bytes([p[i], p[i + 1], p[i + 2], p[i + 3]]); - Some(MasteringDisplay { - primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))], - white_point: (u16_at(12), u16_at(14)), - max_luminance: u32_at(16), - min_luminance: u32_at(20), - }) - } -} - -/// `colr` box contents. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ColorInfo { - /// ISO `nclx` (with full-range flag). - Nclx { primaries: u16, transfer: u16, matrix: u16, full_range: bool }, - /// QuickTime `nclc`. - Nclc { primaries: u16, transfer: u16, matrix: u16 }, - /// Embedded ICC profile (`prof` restricted/unrestricted `rICC`). - Icc { kind: FourCc, profile: Vec }, -} - -/// `clap` clean aperture (all rationals num/den; offsets are signed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CleanAperture { - pub width: (u32, u32), - pub height: (u32, u32), - pub horiz_offset: (i32, u32), - pub vert_offset: (i32, u32), -} - -/// QuickTime `fiel`: field count (1 progressive, 2 interlaced) and ordering detail -/// (0, 1 = top first, 6 = bottom first, 9 / 14 per the QuickTime spec). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FieldInfo { - pub fields: u8, - pub detail: u8, -} - -/// `btrt` box. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct BitRate { - pub buffer_size: u32, - pub max_bitrate: u32, - pub avg_bitrate: u32, -} - -/// Fields of audio sample entries (ISO v0 plus QuickTime sound description v1/v2 extensions). -#[derive(Clone, Debug, PartialEq, Default)] -pub struct AudioParams { - /// QuickTime sound description version (0, 1, 2). ISO files use 0. - pub qt_version: u16, - pub channels: u32, - /// Bits per sample from the entry (16 for most compressed formats). - pub sample_size: u16, - pub sample_rate: f64, - pub compression_id: i16, - /// v1 fields. - pub samples_per_packet: u32, - pub bytes_per_packet: u32, - pub bytes_per_frame: u32, - pub bytes_per_sample: u32, - /// v2 fields. - pub const_bits_per_channel: u32, - pub lpcm_flags: u32, - pub const_bytes_per_packet: u32, - pub const_frames_per_packet: u32, - /// `enda` little-endian flag inside a QuickTime `wave` box. - pub little_endian: Option, -} - -/// Codec configuration decoded from the sample entry. -#[derive(Clone, Debug, PartialEq)] -pub enum CodecConfig { - Avc(AvcConfig), - Hevc(HevcConfig), - Av1(Av1Config), - Vp9(VpcConfig), - /// Apple ProRes; fourcc is `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`. - ProRes { - fourcc: FourCc, - }, - /// Motion JPEG (`jpeg`, `mjpa`, `mjpb`). - Jpeg { - fourcc: FourCc, - }, - /// Avid DNxHD/DNxHR (`AVdn`, `AVdh`). - Dnx { - fourcc: FourCc, - }, - /// Advanced Professional Video (`apv1` + `apvC`). - Apv(ApvConfig), - Aac(AacConfig), - /// MPEG-1/2 audio (esds object type 0x69/0x6B, or QuickTime `.mp3` / `.mp2`). Usually layer - /// III, but these name no layer: ffmpeg stores MP2 as `mp4a` + 0x6B. Each frame header has it. - Mp3, - Pcm(PcmConfig), - /// ALAC magic cookie (ALACSpecificConfig, 24 bytes, optionally followed by a channel layout). - Alac { - cookie: Vec, - }, - Opus(OpusConfig), - Flac(FlacConfig), - /// `dac3` payload. - Ac3 { - dac3: Vec, - }, - /// `dec3` payload. - Eac3 { - dec3: Vec, - }, - Timecode(TimecodeConfig), - /// Anything else; `raw` is the full sample entry payload (after the 8-byte box header). - Unknown { - fourcc: FourCc, - raw: Vec, - }, -} - -impl CodecConfig { - /// A short human-readable codec name. - pub fn name(&self) -> &'static str { - match self { - CodecConfig::Avc(_) => "h264", - CodecConfig::Hevc(_) => "hevc", - CodecConfig::Av1(_) => "av1", - CodecConfig::Vp9(_) => "vp9", - CodecConfig::ProRes { .. } => "prores", - CodecConfig::Jpeg { .. } => "mjpeg", - CodecConfig::Dnx { .. } => "dnxhd", - CodecConfig::Apv(_) => "apv", - CodecConfig::Aac(_) => "aac", - CodecConfig::Mp3 => "mp3", - CodecConfig::Pcm(_) => "pcm", - CodecConfig::Alac { .. } => "alac", - CodecConfig::Opus(_) => "opus", - CodecConfig::Flac(_) => "flac", - CodecConfig::Ac3 { .. } => "ac3", - CodecConfig::Eac3 { .. } => "eac3", - CodecConfig::Timecode(_) => "timecode", - CodecConfig::Unknown { .. } => "unknown", - } - } -} - -/// `avcC` — AVCDecoderConfigurationRecord (ISO/IEC 14496-15 §5.3.3). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct AvcConfig { - pub profile: u8, - pub compatibility: u8, - pub level: u8, - /// NAL length field size in bytes (1, 2 or 4). - pub length_size: u8, - pub sps: Vec>, - pub pps: Vec>, - /// Bytes after the PPS list (high-profile extension: chroma format, bit depths, SPS-ext). - pub ext: Vec, -} - -impl AvcConfig { - /// Build from parameter sets (NAL units without start codes); profile/level taken from the first SPS. - pub fn new(sps: Vec>, pps: Vec>, length_size: u8) -> Self { - let (profile, compatibility, level) = sps.first().filter(|s| s.len() >= 4).map(|s| (s[1], s[2], s[3])).unwrap_or((0, 0, 0)); - AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: Vec::new() } - } - - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "avcC"); - let _version = c.u8()?; - let profile = c.u8()?; - let compatibility = c.u8()?; - let level = c.u8()?; - let length_size = (c.u8()? & 3) + 1; - let nsps = (c.u8()? & 0x1F) as usize; - let mut sps = Vec::with_capacity(nsps); - for _ in 0..nsps { - let n = c.u16()? as usize; - sps.push(c.bytes(n)?.to_vec()); - } - let npps = c.u8()? as usize; - let mut pps = Vec::with_capacity(npps); - for _ in 0..npps { - let n = c.u16()? as usize; - pps.push(c.bytes(n)?.to_vec()); - } - Ok(AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: c.rest().to_vec() }) - } - - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(1); - b.u8(self.profile); - b.u8(self.compatibility); - b.u8(self.level); - b.u8(0xFC | (self.length_size.clamp(1, 4) - 1)); - b.u8(0xE0 | (self.sps.len().min(31) as u8)); - for s in self.sps.iter().take(31) { - b.u16(s.len() as u16); - b.bytes(s); - } - b.u8(self.pps.len().min(255) as u8); - for p in self.pps.iter().take(255) { - b.u16(p.len() as u16); - b.bytes(p); - } - b.bytes(&self.ext); - b.buf - } -} - -/// One NAL array of an `hvcC` record. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct HevcNalArray { - pub completeness: bool, - pub nal_type: u8, - pub nalus: Vec>, -} - -/// `hvcC` — HEVCDecoderConfigurationRecord (ISO/IEC 14496-15 §8.3.3). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct HevcConfig { - pub general_profile_space: u8, - pub general_tier_flag: bool, - pub general_profile_idc: u8, - pub general_profile_compatibility_flags: u32, - /// 48 bits of general constraint indicator flags. - pub general_constraint_indicator_flags: u64, - pub general_level_idc: u8, - pub min_spatial_segmentation_idc: u16, - pub parallelism_type: u8, - pub chroma_format_idc: u8, - pub bit_depth_luma: u8, - pub bit_depth_chroma: u8, - pub avg_frame_rate: u16, - pub constant_frame_rate: u8, - pub num_temporal_layers: u8, - pub temporal_id_nested: bool, - pub length_size: u8, - pub arrays: Vec, -} - -impl HevcConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "hvcC"); - let _version = c.u8()?; - let b = c.u8()?; - let compat = c.u32()?; - let hi = c.u16()? as u64; - let lo = c.u32()? as u64; - let level = c.u8()?; - let min_seg = c.u16()? & 0x0FFF; - let par = c.u8()? & 3; - let chroma = c.u8()? & 3; - let bdl = (c.u8()? & 7) + 8; - let bdc = (c.u8()? & 7) + 8; - let afr = c.u16()?; - let x = c.u8()?; - let narrays = c.u8()? as usize; - let mut arrays = Vec::new(); - for _ in 0..narrays { - let t = c.u8()?; - let n = c.u16()? as usize; - c.check_count(n, 2)?; - let mut nalus = Vec::with_capacity(n); - for _ in 0..n { - let len = c.u16()? as usize; - nalus.push(c.bytes(len)?.to_vec()); - } - arrays.push(HevcNalArray { completeness: t & 0x80 != 0, nal_type: t & 0x3F, nalus }); - } - Ok(HevcConfig { - general_profile_space: b >> 6, - general_tier_flag: b & 0x20 != 0, - general_profile_idc: b & 0x1F, - general_profile_compatibility_flags: compat, - general_constraint_indicator_flags: (hi << 32) | lo, - general_level_idc: level, - min_spatial_segmentation_idc: min_seg, - parallelism_type: par, - chroma_format_idc: chroma, - bit_depth_luma: bdl, - bit_depth_chroma: bdc, - avg_frame_rate: afr, - constant_frame_rate: x >> 6, - num_temporal_layers: (x >> 3) & 7, - temporal_id_nested: x & 4 != 0, - length_size: (x & 3) + 1, - arrays, - }) - } - - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(1); - b.u8((self.general_profile_space << 6) | ((self.general_tier_flag as u8) << 5) | (self.general_profile_idc & 0x1F)); - b.u32(self.general_profile_compatibility_flags); - b.u16((self.general_constraint_indicator_flags >> 32) as u16); - b.u32(self.general_constraint_indicator_flags as u32); - b.u8(self.general_level_idc); - b.u16(0xF000 | (self.min_spatial_segmentation_idc & 0x0FFF)); - b.u8(0xFC | (self.parallelism_type & 3)); - b.u8(0xFC | (self.chroma_format_idc & 3)); - b.u8(0xF8 | (self.bit_depth_luma.saturating_sub(8) & 7)); - b.u8(0xF8 | (self.bit_depth_chroma.saturating_sub(8) & 7)); - b.u16(self.avg_frame_rate); - b.u8((self.constant_frame_rate << 6) - | ((self.num_temporal_layers & 7) << 3) - | ((self.temporal_id_nested as u8) << 2) - | (self.length_size.clamp(1, 4) - 1)); - b.u8(self.arrays.len() as u8); - for a in &self.arrays { - b.u8(((a.completeness as u8) << 7) | (a.nal_type & 0x3F)); - b.u16(a.nalus.len() as u16); - for n in &a.nalus { - b.u16(n.len() as u16); - b.bytes(n); - } - } - b.buf - } - - fn nalus_of(&self, t: u8) -> Vec<&[u8]> { - self.arrays.iter().filter(|a| a.nal_type == t).flat_map(|a| a.nalus.iter().map(|n| n.as_slice())).collect() - } - pub fn vps(&self) -> Vec<&[u8]> { - self.nalus_of(32) - } - pub fn sps(&self) -> Vec<&[u8]> { - self.nalus_of(33) - } - pub fn pps(&self) -> Vec<&[u8]> { - self.nalus_of(34) - } -} - -/// `av1C` — AV1CodecConfigurationRecord. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct Av1Config { - pub seq_profile: u8, - pub seq_level_idx_0: u8, - pub seq_tier_0: bool, - pub high_bitdepth: bool, - pub twelve_bit: bool, - pub monochrome: bool, - pub chroma_subsampling_x: bool, - pub chroma_subsampling_y: bool, - pub chroma_sample_position: u8, - pub initial_presentation_delay_minus_one: Option, - pub config_obus: Vec, -} - -impl Av1Config { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "av1C"); - let m = c.u8()?; - if m & 0x80 == 0 { - return Err(Error::Invalid("av1C marker bit not set".into())); - } - let a = c.u8()?; - let b = c.u8()?; - let d = c.u8()?; - Ok(Av1Config { - seq_profile: a >> 5, - seq_level_idx_0: a & 0x1F, - seq_tier_0: b & 0x80 != 0, - high_bitdepth: b & 0x40 != 0, - twelve_bit: b & 0x20 != 0, - monochrome: b & 0x10 != 0, - chroma_subsampling_x: b & 0x08 != 0, - chroma_subsampling_y: b & 0x04 != 0, - chroma_sample_position: b & 3, - initial_presentation_delay_minus_one: if d & 0x10 != 0 { Some(d & 0x0F) } else { None }, - config_obus: c.rest().to_vec(), - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(0x81); - b.u8((self.seq_profile << 5) | (self.seq_level_idx_0 & 0x1F)); - b.u8(((self.seq_tier_0 as u8) << 7) - | ((self.high_bitdepth as u8) << 6) - | ((self.twelve_bit as u8) << 5) - | ((self.monochrome as u8) << 4) - | ((self.chroma_subsampling_x as u8) << 3) - | ((self.chroma_subsampling_y as u8) << 2) - | (self.chroma_sample_position & 3)); - b.u8(match self.initial_presentation_delay_minus_one { - Some(d) => 0x10 | (d & 0x0F), - None => 0, - }); - b.bytes(&self.config_obus); - b.buf - } -} - -/// `vpcC` — VP codec configuration (version 1). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct VpcConfig { - pub profile: u8, - pub level: u8, - pub bit_depth: u8, - pub chroma_subsampling: u8, - pub full_range: bool, - pub colour_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coefficients: u8, - pub codec_init: Vec, -} - -impl VpcConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "vpcC"); - let (version, _) = c.full_header()?; - if version == 0 { - // Version 0 draft layout: profile, level, bitDepth(4)|colorSpace(4), chroma(4)|transfer(3)|range(1) - let profile = c.u8()?; - let level = c.u8()?; - let a = c.u8()?; - let b = c.u8()?; - return Ok(VpcConfig { profile, level, bit_depth: a >> 4, chroma_subsampling: b >> 4, full_range: b & 1 != 0, ..Default::default() }); - } - let profile = c.u8()?; - let level = c.u8()?; - let a = c.u8()?; - let colour_primaries = c.u8()?; - let transfer_characteristics = c.u8()?; - let matrix_coefficients = c.u8()?; - let n = c.u16()? as usize; - let codec_init = c.bytes(n)?.to_vec(); - Ok(VpcConfig { - profile, - level, - bit_depth: a >> 4, - chroma_subsampling: (a >> 1) & 7, - full_range: a & 1 != 0, - colour_primaries, - transfer_characteristics, - matrix_coefficients, - codec_init, - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u32(1 << 24); - b.u8(self.profile); - b.u8(self.level); - b.u8((self.bit_depth << 4) | ((self.chroma_subsampling & 7) << 1) | self.full_range as u8); - b.u8(self.colour_primaries); - b.u8(self.transfer_characteristics); - b.u8(self.matrix_coefficients); - b.u16(self.codec_init.len() as u16); - b.bytes(&self.codec_init); - b.buf - } -} - -/// AAC (MPEG-4 audio) configuration from `esds`. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct AacConfig { - /// AudioSpecificConfig bytes (DecoderSpecificInfo). - pub asc: Vec, - /// Audio object type (2 = AAC-LC, 5 = SBR/HE-AAC, 29 = PS …) as signalled first in the ASC. - pub object_type: u8, - /// Sampling rate signalled by the ASC (core rate; 0 if the ASC is missing). - pub sample_rate: u32, - /// Channel configuration from the ASC (0 = defined elsewhere). - pub channel_config: u8, - /// DecoderConfigDescriptor fields. - pub max_bitrate: u32, - pub avg_bitrate: u32, - pub buffer_size: u32, -} - -const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; - -impl AacConfig { - /// Build from an AudioSpecificConfig, decoding its header fields. - pub fn from_asc(asc: Vec) -> Self { - let (object_type, sample_rate, channel_config) = parse_asc(&asc).unwrap_or((0, 0, 0)); - AacConfig { asc, object_type, sample_rate, channel_config, max_bitrate: 0, avg_bitrate: 0, buffer_size: 0 } - } - /// Channel count implied by the channel configuration (ISO/IEC 14496-3 Table 1.19 with Amd 4: - /// 7 and 12 are 7.1 front / back, 11 is 6.1, 13 is 22.2, 14 is 7.1 with front heights), 0 if - /// unspecified (a PCE) or reserved. - pub fn channels(&self) -> u32 { - match self.channel_config { - 1..=6 => self.channel_config as u32, - 11 => 7, - 7 | 12 | 14 => 8, - 13 => 24, - _ => 0, - } - } -} - -/// Decode (audio object type, sampling frequency, channel configuration) from an AudioSpecificConfig. -pub fn parse_asc(asc: &[u8]) -> Option<(u8, u32, u8)> { - let mut r = BitReader::new(asc); - let aot = |r: &mut BitReader| -> Option { - let t = r.read_bits(5).ok()? as u8; - if t == 31 { Some(32 + r.read_bits(6).ok()? as u8) } else { Some(t) } - }; - let rate = |r: &mut BitReader| -> Option { - let i = r.read_bits(4).ok()? as usize; - if i == 15 { r.read_bits(24).ok() } else { AAC_RATES.get(i).copied() } - }; - let t = aot(&mut r)?; - let sr = rate(&mut r)?; - let ch = r.read_bits(4).ok()? as u8; - Some((t, sr, ch)) -} - -/// Uncompressed PCM description. -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub struct PcmConfig { - pub bits: u16, - pub float: bool, - pub big_endian: bool, - /// Signed integer samples (8-bit `raw ` is unsigned). - pub signed: bool, - pub channels: u32, - pub sample_rate: f64, -} - -impl PcmConfig { - /// Bytes per interleaved audio frame (all channels). - pub fn bytes_per_frame(&self) -> u32 { - (self.bits as u32).div_ceil(8) * self.channels.max(1) - } -} - -/// `dOps` — Opus specific box. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct OpusConfig { - pub output_channels: u8, - pub pre_skip: u16, - pub input_sample_rate: u32, - pub output_gain: i16, - pub channel_mapping_family: u8, - /// StreamCount, CoupledCount and ChannelMapping bytes when family != 0. - pub channel_mapping: Vec, -} - -impl OpusConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "dOps"); - let _version = c.u8()?; - Ok(OpusConfig { - output_channels: c.u8()?, - pre_skip: c.u16()?, - input_sample_rate: c.u32()?, - output_gain: c.i16()?, - channel_mapping_family: c.u8()?, - channel_mapping: c.rest().to_vec(), - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(0); - b.u8(self.output_channels); - b.u16(self.pre_skip); - b.u32(self.input_sample_rate); - b.i16(self.output_gain); - b.u8(self.channel_mapping_family); - b.bytes(&self.channel_mapping); - b.buf - } -} - -/// `dfLa` — FLAC metadata blocks, with STREAMINFO decoded. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct FlacConfig { - /// Raw metadata blocks (each with its 4-byte header) as stored in `dfLa`. - pub metadata_blocks: Vec, - pub sample_rate: u32, - pub channels: u8, - pub bits_per_sample: u8, - pub total_samples: u64, -} - -impl FlacConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "dfLa"); - c.full_header()?; - let blocks = c.rest().to_vec(); - let mut cfg = FlacConfig { metadata_blocks: blocks.clone(), ..Default::default() }; - let mut b = Cur::new(&blocks, "dfLa"); - let hdr = b.u32()?; - if (hdr >> 24) & 0x7F == 0 { - let si = b.bytes(((hdr & 0xFF_FFFF) as usize).min(34))?; - if si.len() >= 18 { - let mut r = BitReader::new(&si[10..18]); - cfg.sample_rate = r.read_bits(20).unwrap_or(0); - cfg.channels = r.read_bits(3).unwrap_or(0) as u8 + 1; - cfg.bits_per_sample = r.read_bits(5).unwrap_or(0) as u8 + 1; - cfg.total_samples = r.read_bits_u64(36).unwrap_or(0); - } - } - Ok(cfg) - } -} - -/// QuickTime timecode sample description (`tmcd`) plus the start frame read from its first sample. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct TimecodeConfig { - /// Bit 0 drop frame, bit 1 24-hour max, bit 2 negative times OK, bit 3 counter. - pub flags: u32, - pub timescale: u32, - pub frame_duration: u32, - /// Nominal frames per second (`numberOfFrames`), e.g. 30 for 29.97. - pub frames_per_second: u8, - /// Frame number of the first timecode sample (filled in by the demuxer). - pub start_frame: Option, - /// Source reel name (`name` box) if present. - pub name: Option, -} - -impl TimecodeConfig { - pub fn drop_frame(&self) -> bool { - self.flags & 1 != 0 - } - /// Format a frame number as `HH:MM:SS:FF` (`;` before the frames when drop-frame). - pub fn format_frame(&self, frame: u32) -> String { - let fps = self.frames_per_second.max(1) as u64; - let mut f = frame as u64; - if self.drop_frame() && fps >= 30 { - let drop = (fps / 30) * 2; // 2 frames per minute at 30, 4 at 60 - let per_min = fps * 60 - drop; - let per_10 = per_min * 10 + drop; - let d = f / per_10; - let m = f % per_10; - f += drop * 9 * d + if m > drop { drop * ((m - drop) / per_min) } else { 0 }; - } - let ff = f % fps; - let s = (f / fps) % 60; - let mi = (f / fps / 60) % 60; - let h = (f / fps / 3600) % 24; - let sep = if self.drop_frame() { ';' } else { ':' }; - format!("{h:02}:{mi:02}:{s:02}{sep}{ff:02}") - } -} - -/// `apvC` — APVDecoderConfigurationBox (`FullBox('apvC', version = 0, flags = 0)` containing -/// `APVDecoderConfigurationRecord`). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct ApvConfig { - pub profile_idc: u8, - pub level_idc: u8, - pub band_idc: u8, - pub frame_width: u32, - pub frame_height: u32, - pub chroma_format_idc: u8, - pub bit_depth_minus8: u8, - pub capture_time_distance: u8, - pub capture_time_distance_ignored: bool, - pub color_description_present: bool, - pub color_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coefficients: u8, - pub full_range: bool, - /// Raw `APVDecoderConfigurationRecord` bytes (after the 4-byte FullBox header). - pub raw_record: Vec, -} - -impl ApvConfig { - /// Build from a raw `APVDecoderConfigurationRecord` (starting with `configurationVersion = 1`). - pub fn from_record(record: Vec) -> Result { - let mut c = Cur::new(&record, "apvC"); - let version = c.u8()?; - if version != 1 { - return Err(Error::Invalid(format!("unsupported APVDecoderConfigurationRecord version {version}"))); - } - let num_entries = c.u8()?; - let mut cfg = ApvConfig { raw_record: record.clone(), ..Default::default() }; - if num_entries >= 1 && c.remaining() >= 2 { - let _pbu_type = c.u8()?; - let num_fi = c.u8()?; - if num_fi >= 1 && c.remaining() >= 14 { - let flags = c.u8()?; - cfg.color_description_present = (flags & 0x02) != 0; - cfg.capture_time_distance_ignored = (flags & 0x01) != 0; - cfg.profile_idc = c.u8()?; - cfg.level_idc = c.u8()?; - cfg.band_idc = c.u8()?; - cfg.frame_width = c.u32()?; - cfg.frame_height = c.u32()?; - let cb = c.u8()?; - cfg.chroma_format_idc = cb >> 4; - cfg.bit_depth_minus8 = cb & 0x0F; - cfg.capture_time_distance = c.u8()?; - if cfg.color_description_present && c.remaining() >= 4 { - cfg.color_primaries = c.u8()?; - cfg.transfer_characteristics = c.u8()?; - cfg.matrix_coefficients = c.u8()?; - cfg.full_range = (c.u8()? & 0x80) != 0; - } - } - } - Ok(cfg) - } - - /// Parse the payload of an `apvC` box (with or without the 4-byte `FullBox` header). - pub fn parse(data: &[u8]) -> Result { - if data.len() >= 5 && data[..4] == [0, 0, 0, 0] && data[4] == 1 { - Self::from_record(data[4..].to_vec()) - } else if !data.is_empty() && data[0] == 1 { - Self::from_record(data.to_vec()) - } else { - let mut c = Cur::new(data, "apvC"); - let (v, _) = c.full_header()?; - if v != 0 { - return Err(Error::Invalid(format!("unsupported apvC FullBox version {v}"))); - } - Self::from_record(c.rest().to_vec()) - } - } - - /// Serialize the full `apvC` box payload (4-byte `FullBox` header + `APVDecoderConfigurationRecord`). - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u32(0); // FullBox version = 0, flags = 0 - if !self.raw_record.is_empty() { - b.bytes(&self.raw_record); - return b.buf; - } - b.u8(1); // configurationVersion = 1 - b.u8(1); // number_of_configuration_entry = 1 - b.u8(1); // pbu_type = 1 (primary frame) - b.u8(1); // number_of_frame_info = 1 - let flags = (if self.color_description_present { 0x02 } else { 0 }) | (if self.capture_time_distance_ignored { 0x01 } else { 0 }); - b.u8(flags); - b.u8(self.profile_idc); - b.u8(self.level_idc); - b.u8(self.band_idc); - b.u32(self.frame_width); - b.u32(self.frame_height); - b.u8((self.chroma_format_idc << 4) | (self.bit_depth_minus8 & 0x0F)); - b.u8(self.capture_time_distance); - if self.color_description_present { - b.u8(self.color_primaries); - b.u8(self.transfer_characteristics); - b.u8(self.matrix_coefficients); - b.u8(if self.full_range { 0x80 } else { 0 }); - } - b.buf - } -} - -// --------------------------------------------------------------------------------------------- -// Parsing - -fn is_video_fourcc(f: &[u8; 4]) -> bool { - matches!( - f, - b"avc1" - | b"avc3" - | b"hvc1" - | b"hev1" - | b"av01" - | b"vp09" - | b"vp08" - | b"apch" - | b"apcn" - | b"apcs" - | b"apco" - | b"ap4h" - | b"ap4x" - | b"apv1" - | b"jpeg" - | b"mjpa" - | b"mjpb" - | b"AVdn" - | b"AVdh" - | b"mp4v" - | b"encv" - | b"raw " - | b"2vuy" - | b"v210" - ) -} - -fn is_audio_fourcc(f: &[u8; 4]) -> bool { - matches!( - f, - b"mp4a" - | b"lpcm" - | b"sowt" - | b"twos" - | b"in24" - | b"in32" - | b"fl32" - | b"fl64" - | b"ipcm" - | b"fpcm" - | b"alac" - | b"Opus" - | b"fLaC" - | b"ac-3" - | b"ec-3" - | b".mp3" - | b".mp2" - | b"enca" - | b"ulaw" - | b"alaw" - | b"NONE" - ) -} - -/// Parse one `stsd` entry. `handler` is the track handler type; `is_qt` selects QuickTime -/// interpretation of the sound description version field. -pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc, is_qt: bool) -> Result { - let mut c = Cur::new(payload, "sample entry"); - c.skip(6)?; - let data_reference_index = c.u16()?; - let f = &format.0; - let unknown = || CodecConfig::Unknown { fourcc: format, raw: payload.to_vec() }; - if f == b"tmcd" || (handler == b"tmcd" && !is_video_fourcc(f)) { - let mut tc = TimecodeConfig::default(); - c.skip(4)?; - tc.flags = c.u32()?; - tc.timescale = c.u32()?; - tc.frame_duration = c.u32()?; - tc.frames_per_second = c.u8()?; - c.skip(1)?; - for b in boxes(c.rest()).map_while(|b| b.ok()) { - if b.kind == b"name" && b.payload.len() >= 4 { - let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; - let s = b.payload.get(4..4 + n).unwrap_or(&b.payload[4..]); - tc.name = Some(String::from_utf8_lossy(s).into_owned()); - } - } - return Ok(SampleEntry { format, data_reference_index, codec: CodecConfig::Timecode(tc), video: None, audio: None, bitrate: None }); - } - let video_like = is_video_fourcc(f) || (handler == b"vide" && !is_audio_fourcc(f)); - let audio_like = is_audio_fourcc(f) || (handler == b"soun" && !video_like); - if video_like { - let mut v = VideoParams::default(); - let _version = c.u16()?; - let _revision = c.u16()?; - v.vendor = c.fourcc()?; - v.temporal_quality = c.u32()?; - v.spatial_quality = c.u32()?; - v.width = c.u16()?; - v.height = c.u16()?; - v.horiz_resolution = c.u32()?; - v.vert_resolution = c.u32()?; - c.skip(4)?; - v.frame_count = c.u16()?; - let name = c.bytes(32)?; - let n = (name[0] as usize).min(31); - v.compressor_name = String::from_utf8_lossy(&name[1..1 + n]).into_owned(); - v.depth = c.u16()?; - let _ctab = c.i16()?; - let mut codec = None; - let mut bitrate = None; - for b in boxes(c.rest()).map_while(|b| b.ok()) { - let p = b.payload; - match &b.kind.0 { - b"avcC" if matches!(f, b"avc1" | b"avc3") => codec = Some(CodecConfig::Avc(AvcConfig::parse(p)?)), - b"hvcC" if matches!(f, b"hvc1" | b"hev1") => codec = Some(CodecConfig::Hevc(HevcConfig::parse(p)?)), - b"av1C" if f == b"av01" => codec = Some(CodecConfig::Av1(Av1Config::parse(p)?)), - b"vpcC" if f == b"vp09" => codec = Some(CodecConfig::Vp9(VpcConfig::parse(p)?)), - b"apvC" if f == b"apv1" => codec = Some(CodecConfig::Apv(ApvConfig::parse(p)?)), - b"colr" => v.color = parse_colr(p).ok(), - b"mdcv" => v.mastering_display = MasteringDisplay::parse(p), - b"clli" if p.len() >= 4 => v.content_light = Some((u16::from_be_bytes([p[0], p[1]]), u16::from_be_bytes([p[2], p[3]]))), - b"pasp" => { - let mut c = Cur::new(p, "pasp"); - v.pixel_aspect = Some((c.u32()?, c.u32()?)); - } - b"clap" => { - let mut c = Cur::new(p, "clap"); - v.clean_aperture = Some(CleanAperture { - width: (c.u32()?, c.u32()?), - height: (c.u32()?, c.u32()?), - horiz_offset: (c.i32()?, c.u32()?), - vert_offset: (c.i32()?, c.u32()?), - }); - } - b"fiel" => { - let mut c = Cur::new(p, "fiel"); - v.field_info = Some(FieldInfo { fields: c.u8()?, detail: c.u8()? }); - } - b"gama" => v.gamma = Some(Cur::new(p, "gama").u32()?), - b"btrt" => bitrate = Some(parse_btrt(p)?), - _ => {} - } - } - let codec = match codec { - Some(c) => c, - None => match f { - b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => CodecConfig::ProRes { fourcc: format }, - b"jpeg" | b"mjpa" | b"mjpb" => CodecConfig::Jpeg { fourcc: format }, - b"AVdn" | b"AVdh" => CodecConfig::Dnx { fourcc: format }, - b"apv1" => CodecConfig::Apv(ApvConfig::default()), - _ => unknown(), - }, - }; - return Ok(SampleEntry { format, data_reference_index, codec, video: Some(v), audio: None, bitrate }); - } - if audio_like { - let mut a = AudioParams { qt_version: c.u16()?, ..Default::default() }; - let _revision = c.u16()?; - let _vendor = c.u32()?; - a.channels = c.u16()? as u32; - a.sample_size = c.u16()?; - a.compression_id = c.i16()?; - let _packet_size = c.u16()?; - a.sample_rate = c.u32()? as f64 / 65536.0; - if is_qt && a.qt_version == 1 { - a.samples_per_packet = c.u32()?; - a.bytes_per_packet = c.u32()?; - a.bytes_per_frame = c.u32()?; - a.bytes_per_sample = c.u32()?; - } else if is_qt && a.qt_version == 2 { - let _size_of_struct = c.u32()?; - a.sample_rate = c.f64()?; - a.channels = c.u32()?; - let _always = c.u32()?; - a.const_bits_per_channel = c.u32()?; - a.lpcm_flags = c.u32()?; - a.const_bytes_per_packet = c.u32()?; - a.const_frames_per_packet = c.u32()?; - } - // Flatten QuickTime `wave` children into the list of extension boxes. - let mut ext: Vec<(FourCc, &[u8])> = Vec::new(); - for b in boxes(c.rest()).map_while(|b| b.ok()) { - if b.kind == b"wave" { - for w in boxes(b.payload).map_while(|b| b.ok()) { - ext.push((w.kind, w.payload)); - } - } else { - ext.push((b.kind, b.payload)); - } - } - let get = |k: &[u8; 4]| ext.iter().find(|(kk, _)| kk == k).map(|(_, p)| *p); - if let Some(p) = get(b"enda") { - let v = if p.len() >= 2 { u16::from_be_bytes([p[0], p[1]]) } else { p.first().copied().unwrap_or(0) as u16 }; - a.little_endian = Some(v != 0); - } - let bitrate = get(b"btrt").and_then(|p| parse_btrt(p).ok()); - let codec = match f { - b"mp4a" => match get(b"esds") { - Some(p) => parse_esds(p)?, - None => unknown(), - }, - b".mp3" | b".mp2" => CodecConfig::Mp3, - b"alac" => match get(b"alac") { - Some(p) if p.len() >= 4 => CodecConfig::Alac { cookie: p[4..].to_vec() }, - _ => unknown(), - }, - b"Opus" => match get(b"dOps") { - Some(p) => CodecConfig::Opus(OpusConfig::parse(p)?), - None => unknown(), - }, - b"fLaC" => match get(b"dfLa") { - Some(p) => CodecConfig::Flac(FlacConfig::parse(p)?), - None => unknown(), - }, - b"ac-3" => CodecConfig::Ac3 { dac3: get(b"dac3").unwrap_or(&[]).to_vec() }, - b"ec-3" => CodecConfig::Eac3 { dec3: get(b"dec3").unwrap_or(&[]).to_vec() }, - b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)), - b"ipcm" | b"fpcm" => { - let mut pcm = - PcmConfig { bits: a.sample_size, float: f == b"fpcm", big_endian: true, signed: true, channels: a.channels, sample_rate: a.sample_rate }; - if let Some(p) = get(b"pcmC") { - let mut c = Cur::new(p, "pcmC"); - c.full_header()?; - let flags = c.u8()?; - pcm.big_endian = flags & 1 == 0; - pcm.bits = c.u8()? as u16; - } - CodecConfig::Pcm(pcm) - } - _ => unknown(), - }; - return Ok(SampleEntry { format, data_reference_index, codec, video: None, audio: Some(a), bitrate }); - } - Ok(SampleEntry { format, data_reference_index, codec: unknown(), video: None, audio: None, bitrate: None }) -} - -fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig { - let mut p = PcmConfig { channels: a.channels, sample_rate: a.sample_rate, signed: true, ..Default::default() }; - match f { - b"lpcm" => { - p.bits = a.const_bits_per_channel as u16; - p.float = a.lpcm_flags & 1 != 0; - p.big_endian = a.lpcm_flags & 2 != 0; - p.signed = a.lpcm_flags & 4 != 0 || p.float; - if p.bits == 0 { - p.bits = a.sample_size; - } - } - b"sowt" => { - p.bits = if a.sample_size == 0 { 16 } else { a.sample_size }; - } - b"twos" => { - p.bits = if a.sample_size == 0 { 16 } else { a.sample_size }; - p.big_endian = true; - } - b"raw " | b"NONE" => { - p.bits = if a.sample_size == 0 { 8 } else { a.sample_size }; - p.signed = p.bits != 8; - p.big_endian = true; - } - b"in24" | b"in32" | b"fl32" | b"fl64" => { - p.bits = match f { - b"in24" => 24, - b"in32" | b"fl32" => 32, - _ => 64, - }; - p.float = f[0] == b'f'; - p.big_endian = !a.little_endian.unwrap_or(false); - } - _ => {} - } - p -} - -fn parse_btrt(p: &[u8]) -> Result { - let mut c = Cur::new(p, "btrt"); - Ok(BitRate { buffer_size: c.u32()?, max_bitrate: c.u32()?, avg_bitrate: c.u32()? }) -} - -fn parse_colr(p: &[u8]) -> Result { - let mut c = Cur::new(p, "colr"); - let kind = c.fourcc()?; - Ok(match &kind.0 { - b"nclx" => ColorInfo::Nclx { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()?, full_range: c.u8().map(|v| v & 0x80 != 0).unwrap_or(false) }, - b"nclc" => ColorInfo::Nclc { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()? }, - _ => ColorInfo::Icc { kind, profile: c.rest().to_vec() }, - }) -} - -fn descriptor(c: &mut Cur) -> Result<(u8, usize)> { - let tag = c.u8()?; - let mut len = 0usize; - for _ in 0..4 { - let b = c.u8()?; - len = (len << 7) | (b & 0x7F) as usize; - if b & 0x80 == 0 { - break; - } - } - Ok((tag, len)) -} - -fn parse_esds(p: &[u8]) -> Result { - let mut c = Cur::new(p, "esds"); - c.full_header()?; - let (tag, len) = descriptor(&mut c)?; - if tag != 3 { - return Err(Error::Invalid(format!("esds: expected ES_Descriptor, got tag {tag}"))); - } - let es = c.bytes(len.min(c.remaining()))?; - let mut c = Cur::new(es, "ES_Descriptor"); - c.u16()?; - let fl = c.u8()?; - if fl & 0x80 != 0 { - c.u16()?; - } - if fl & 0x40 != 0 { - let n = c.u8()? as usize; - c.skip(n)?; - } - if fl & 0x20 != 0 { - c.u16()?; - } - while c.remaining() > 0 { - let (tag, len) = descriptor(&mut c)?; - let body = c.bytes(len.min(c.remaining()))?; - if tag != 4 { - continue; - } - let mut d = Cur::new(body, "DecoderConfigDescriptor"); - let oti = d.u8()?; - let _stream_type = d.u8()?; - let buffer_size = d.u24()?; - let max_bitrate = d.u32()?; - let avg_bitrate = d.u32()?; - let mut asc = Vec::new(); - while d.remaining() > 0 { - let (t, l) = descriptor(&mut d)?; - let b = d.bytes(l.min(d.remaining()))?; - if t == 5 { - asc = b.to_vec(); - break; - } - } - return Ok(match oti { - 0x69 | 0x6B => CodecConfig::Mp3, - _ => { - let mut a = AacConfig::from_asc(asc); - a.max_bitrate = max_bitrate; - a.avg_bitrate = avg_bitrate; - a.buffer_size = buffer_size; - CodecConfig::Aac(a) - } - }); - } - Err(Error::Invalid("esds without DecoderConfigDescriptor".into())) -} - -// --------------------------------------------------------------------------------------------- -// Serialization (muxer) - -fn put_descriptor(b: &mut BoxBuf, tag: u8, body: &[u8]) { - b.u8(tag); - let n = body.len() as u32; - // Always use the 4-byte length form for simplicity (as many encoders do). - b.u8(0x80 | ((n >> 21) & 0x7F) as u8); - b.u8(0x80 | ((n >> 14) & 0x7F) as u8); - b.u8(0x80 | ((n >> 7) & 0x7F) as u8); - b.u8((n & 0x7F) as u8); - b.bytes(body); -} - -pub(crate) fn esds_payload(oti: u8, asc: &[u8], max_bitrate: u32, avg_bitrate: u32, buffer_size: u32) -> Vec { - let mut dcd = BoxBuf::new(); - dcd.u8(oti); - dcd.u8(0x15); // streamType audio (5) << 2 | upstream 0 | reserved 1 - dcd.u8((buffer_size >> 16) as u8); - dcd.u16(buffer_size as u16); - dcd.u32(max_bitrate); - dcd.u32(avg_bitrate); - if !asc.is_empty() { - put_descriptor(&mut dcd, 5, asc); - } - let mut es = BoxBuf::new(); - es.u16(1); // ES_ID - es.u8(0); - put_descriptor(&mut es, 4, &dcd.buf); - put_descriptor(&mut es, 6, &[2]); // SLConfigDescriptor, predefined = 2 (MP4) - let mut b = BoxBuf::new(); - b.u32(0); - put_descriptor(&mut b, 3, &es.buf); - b.buf -} - -impl SampleEntry { - /// Convenience constructor for a video entry. - pub fn video(format: FourCc, codec: CodecConfig, width: u16, height: u16) -> Self { - let compressor_name = match &codec { - CodecConfig::ProRes { fourcc } => match &fourcc.0 { - b"apch" => "Apple ProRes 422 HQ", - b"apcn" => "Apple ProRes 422", - b"apcs" => "Apple ProRes 422 LT", - b"apco" => "Apple ProRes 422 Proxy", - b"ap4h" => "Apple ProRes 4444", - _ => "Apple ProRes 4444 XQ", - }, - CodecConfig::Jpeg { .. } => "Photo - JPEG", - _ => "", - }; - SampleEntry { - format, - data_reference_index: 1, - codec, - video: Some(VideoParams { - width, - height, - horiz_resolution: 0x0048_0000, - vert_resolution: 0x0048_0000, - frame_count: 1, - compressor_name: compressor_name.into(), - depth: 24, - ..Default::default() - }), - audio: None, - bitrate: None, - } - } - - /// Convenience constructor for an audio entry (ISO v0 layout; the muxer upgrades PCM in MOV to v2). - pub fn audio(format: FourCc, codec: CodecConfig, channels: u32, sample_rate: f64, sample_size: u16) -> Self { - SampleEntry { - format, - data_reference_index: 1, - codec, - video: None, - audio: Some(AudioParams { channels, sample_rate, sample_size, ..Default::default() }), - bitrate: None, - } - } - - /// H.264 entry (`avc1`). - pub fn avc(cfg: AvcConfig, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"avc1"), CodecConfig::Avc(cfg), width, height) - } - /// HEVC entry (`hvc1`, parameter sets in the config only). - pub fn hevc(cfg: HevcConfig, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"hvc1"), CodecConfig::Hevc(cfg), width, height) - } - /// AV1 entry (`av01`). - pub fn av1(cfg: Av1Config, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), width, height) - } - /// ProRes entry; `fourcc` one of `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`. - pub fn prores(fourcc: FourCc, width: u16, height: u16) -> Self { - let mut e = Self::video(fourcc, CodecConfig::ProRes { fourcc }, width, height); - if let Some(v) = e.video.as_mut() - && matches!(&fourcc.0, b"ap4h" | b"ap4x") - { - v.depth = 32; - } - e - } - /// Avid DNxHD / DNxHR entry (`AVdn`, or `AVdh` for DNxHR). - pub fn dnx(fourcc: FourCc, width: u16, height: u16) -> Self { - let mut e = Self::video(fourcc, CodecConfig::Dnx { fourcc }, width, height); - if let Some(v) = e.video.as_mut() { - v.depth = 24; - } - e - } - /// Advanced Professional Video entry (`apv1` + `apvC`). - pub fn apv(cfg: ApvConfig, width: u16, height: u16) -> Self { - let mut e = Self::video(FourCc(*b"apv1"), CodecConfig::Apv(cfg), width, height); - if let Some(v) = e.video.as_mut() { - v.compressor_name = "APV".into(); - } - e - } - /// Motion-JPEG entry (`jpeg`). - pub fn jpeg(width: u16, height: u16) -> Self { - let f = FourCc(*b"jpeg"); - Self::video(f, CodecConfig::Jpeg { fourcc: f }, width, height) - } - /// AAC entry (`mp4a` + `esds`) from an AudioSpecificConfig. - pub fn aac(asc: Vec, channels: u32, sample_rate: u32) -> Self { - Self::audio(FourCc(*b"mp4a"), CodecConfig::Aac(AacConfig::from_asc(asc)), channels, sample_rate as f64, 16) - } - /// FLAC entry (`fLaC` + `dfLa`) from the 34-byte STREAMINFO body: the only metadata block. - pub fn flac(streaminfo: &[u8; 34]) -> Self { - let mut blocks = vec![0x80, 0, 0, 34]; - blocks.extend_from_slice(streaminfo); - let mut full = vec![0; 4]; - full.extend_from_slice(&blocks); - let cfg = FlacConfig::parse(&full).unwrap_or(FlacConfig { metadata_blocks: blocks, ..Default::default() }); - let (channels, rate, bits) = (u32::from(cfg.channels), f64::from(cfg.sample_rate), u16::from(cfg.bits_per_sample)); - Self::audio(FourCc(*b"fLaC"), CodecConfig::Flac(cfg), channels, rate, bits) - } - /// PCM entry. The muxer picks the on-disk format (`sowt`/`twos`/`lpcm` for MOV, `ipcm`/`fpcm` for MP4). - pub fn pcm(cfg: PcmConfig) -> Self { - Self::audio(FourCc(*b"lpcm"), CodecConfig::Pcm(cfg), cfg.channels, cfg.sample_rate, cfg.bits) - } - - /// Serialize as a complete sample entry box. For PCM the format is chosen from the brand. - pub(crate) fn write(&self, b: &mut BoxBuf, qt: bool) -> Result<()> { - if let CodecConfig::Unknown { fourcc, raw } = &self.codec { - let m = b.start(&fourcc.0); - b.bytes(raw); - b.end(m); - return Ok(()); - } - if let CodecConfig::Timecode(tc) = &self.codec { - let m = b.start(b"tmcd"); - b.zeros(6); - b.u16(self.data_reference_index); - b.u32(0); - b.u32(tc.flags); - b.u32(tc.timescale); - b.u32(tc.frame_duration); - b.u8(tc.frames_per_second); - b.u8(0); - if let Some(name) = &tc.name { - let n = b.start(b"name"); - b.u16(name.len() as u16); - b.u16(0); - b.bytes(name.as_bytes()); - b.end(n); - } - b.end(m); - return Ok(()); - } - if let Some(v) = &self.video { - let m = b.start(&self.format.0); - b.zeros(6); - b.u16(self.data_reference_index); - b.u16(0); - b.u16(0); - b.u32(v.vendor.as_u32()); - b.u32(v.temporal_quality); - b.u32(v.spatial_quality); - b.u16(v.width); - b.u16(v.height); - b.u32(v.horiz_resolution); - b.u32(v.vert_resolution); - b.u32(0); - b.u16(v.frame_count); - let name = v.compressor_name.as_bytes(); - let n = name.len().min(31); - let mut nb = [0u8; 32]; - nb[0] = n as u8; - nb[1..1 + n].copy_from_slice(&name[..n]); - b.bytes(&nb); - b.u16(v.depth); - b.i16(-1); - match &self.codec { - CodecConfig::Avc(c) => b.leaf(b"avcC", &c.to_bytes()), - CodecConfig::Hevc(c) => b.leaf(b"hvcC", &c.to_bytes()), - CodecConfig::Av1(c) => b.leaf(b"av1C", &c.to_bytes()), - CodecConfig::Vp9(c) => b.leaf(b"vpcC", &c.to_bytes()), - CodecConfig::Apv(c) => b.leaf(b"apvC", &c.to_bytes()), - _ => {} - } - if let Some(g) = v.gamma { - b.leaf(b"gama", &g.to_be_bytes()); - } - if let Some(fi) = v.field_info { - b.leaf(b"fiel", &[fi.fields, fi.detail]); - } - if let Some(ci) = &v.color { - let m = b.start(b"colr"); - match ci { - ColorInfo::Nclx { primaries, transfer, matrix, full_range } => { - b.bytes(b"nclx"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - b.u8(if *full_range { 0x80 } else { 0 }); - } - ColorInfo::Nclc { primaries, transfer, matrix } => { - b.bytes(b"nclc"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - } - ColorInfo::Icc { kind, profile } => { - b.bytes(&kind.0); - b.bytes(profile); - } - } - b.end(m); - } - if let Some(md) = &v.mastering_display { - b.leaf(b"mdcv", &md.to_bytes()); - } - if let Some((cll, fall)) = v.content_light { - let mut d = cll.to_be_bytes().to_vec(); - d.extend_from_slice(&fall.to_be_bytes()); - b.leaf(b"clli", &d); - } - if let Some((h, vv)) = v.pixel_aspect { - let m = b.start(b"pasp"); - b.u32(h); - b.u32(vv); - b.end(m); - } - if let Some(c) = v.clean_aperture { - let m = b.start(b"clap"); - b.u32(c.width.0); - b.u32(c.width.1); - b.u32(c.height.0); - b.u32(c.height.1); - b.i32(c.horiz_offset.0); - b.u32(c.horiz_offset.1); - b.i32(c.vert_offset.0); - b.u32(c.vert_offset.1); - b.end(m); - } - if let Some(br) = self.bitrate { - write_btrt(b, br); - } - b.end(m); - return Ok(()); - } - let a = self.audio.clone().ok_or_else(|| Error::Mux("sample entry has neither video nor audio params".into()))?; - // Choose the on-disk format and sound description version. - let (format, version, pcm) = match &self.codec { - CodecConfig::Pcm(p) => { - if qt { - if p.bits == 16 && !p.float { - (if p.big_endian { *b"twos" } else { *b"sowt" }, 0u16, Some(*p)) - } else if p.bits == 8 && !p.float { - (*b"raw ", 0, Some(*p)) - } else { - (*b"lpcm", 2, Some(*p)) - } - } else { - (if p.float { *b"fpcm" } else { *b"ipcm" }, 0, Some(*p)) - } - } - _ => (self.format.0, if qt { a.qt_version.min(2) } else { 0 }, None), - }; - let m = b.start(&format); - b.zeros(6); - b.u16(self.data_reference_index); - if version == 2 { - let p = pcm.unwrap_or_default(); - b.u16(2); - b.u16(0); - b.u32(0); - b.u16(3); - b.u16(16); - b.i16(-2); - b.u16(0); - b.u32(65536); - b.u32(72); - b.f64(p.sample_rate); - b.u32(p.channels); - b.u32(0x7F00_0000); - b.u32(p.bits as u32); - let flags = (p.float as u32) | ((p.big_endian as u32) << 1) | (((p.signed && !p.float) as u32) << 2) | 8; - b.u32(flags); - b.u32(p.bytes_per_frame()); - b.u32(1); - } else { - b.u16(version); - b.u16(0); - b.u32(0); - b.u16(a.channels.min(u16::MAX as u32) as u16); - b.u16(match pcm { - Some(p) => p.bits, - None => { - if a.sample_size == 0 { - 16 - } else { - a.sample_size - } - } - }); - b.i16(if version == 1 { a.compression_id } else { 0 }); - b.u16(0); - let sr = if a.sample_rate < 65536.0 { (a.sample_rate * 65536.0).round() as u32 } else { 0 }; - b.u32(sr); - if version == 1 { - b.u32(a.samples_per_packet); - b.u32(a.bytes_per_packet); - b.u32(a.bytes_per_frame); - b.u32(a.bytes_per_sample); - } - } - match &self.codec { - CodecConfig::Aac(c) => { - b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size)); - } - CodecConfig::Mp3 if format == *b"mp4a" => { - b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); - } - CodecConfig::Alac { cookie } => { - let m = b.start_full(b"alac", 0, 0); - b.bytes(cookie); - b.end(m); - } - CodecConfig::Opus(o) => b.leaf(b"dOps", &o.to_bytes()), - CodecConfig::Flac(fl) => { - let m = b.start_full(b"dfLa", 0, 0); - b.bytes(&fl.metadata_blocks); - b.end(m); - } - CodecConfig::Ac3 { dac3 } => b.leaf(b"dac3", dac3), - CodecConfig::Eac3 { dec3 } => b.leaf(b"dec3", dec3), - CodecConfig::Pcm(p) if !qt => { - let m = b.start_full(b"pcmC", 0, 0); - b.u8(if p.big_endian { 0 } else { 1 }); - b.u8(p.bits as u8); - b.end(m); - } - CodecConfig::Pcm(p) if p.channels == 6 => { - // QuickTime channel layout: kAudioChannelLayoutTag_MPEG_5_1_A (L R C LFE Ls Rs) - let m = b.start_full(b"chan", 0, 0); - b.u32((121 << 16) | 6); - b.u32(0); - b.u32(0); - b.end(m); - } - _ => {} - } - if let Some(br) = self.bitrate { - write_btrt(b, br); - } - b.end(m); - Ok(()) - } -} - -fn write_btrt(b: &mut BoxBuf, br: BitRate) { - let m = b.start(b"btrt"); - b.u32(br.buffer_size); - b.u32(br.max_bitrate); - b.u32(br.avg_bitrate); - b.end(m); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(e: &SampleEntry, qt: bool, handler: &[u8; 4]) -> SampleEntry { - let mut b = BoxBuf::new(); - e.write(&mut b, qt).unwrap(); - let bx = crate::bytes::boxes(&b.buf).next().unwrap().unwrap(); - parse_sample_entry(bx.kind, bx.payload, FourCc(*handler), qt).unwrap() - } - - #[test] - fn avcc_roundtrip() { - let cfg = AvcConfig::new(vec![vec![0x67, 0x64, 0x00, 0x1F, 0xAC]], vec![vec![0x68, 0xEB]], 4); - assert_eq!(cfg.profile, 0x64); - assert_eq!(AvcConfig::parse(&cfg.to_bytes()).unwrap(), cfg); - let mut e = SampleEntry::avc(cfg, 1920, 1080); - e.video.as_mut().unwrap().color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }); - e.video.as_mut().unwrap().pixel_aspect = Some((4, 3)); - e.video.as_mut().unwrap().field_info = Some(FieldInfo { fields: 2, detail: 9 }); - e.video.as_mut().unwrap().clean_aperture = Some(CleanAperture { width: (1920, 1), height: (1080, 1), horiz_offset: (-2, 1), vert_offset: (0, 1) }); - e.video.as_mut().unwrap().mastering_display = Some(MasteringDisplay::bt2020(1000.0, 0.0001)); - e.video.as_mut().unwrap().content_light = Some((1000, 400)); - e.bitrate = Some(BitRate { buffer_size: 1, max_bitrate: 2, avg_bitrate: 3 }); - assert_eq!(roundtrip(&e, false, b"vide"), e); - assert_eq!(MasteringDisplay::bt2020(1000.0, 0.0001).max_nits(), 1000.0); - } - - #[test] - fn hvcc_roundtrip() { - let cfg = HevcConfig { - general_profile_idc: 1, - general_profile_compatibility_flags: 0x6000_0000, - general_constraint_indicator_flags: 0x9000_0000_0000, - general_level_idc: 93, - chroma_format_idc: 1, - bit_depth_luma: 8, - bit_depth_chroma: 8, - length_size: 4, - num_temporal_layers: 1, - temporal_id_nested: true, - arrays: vec![ - HevcNalArray { completeness: true, nal_type: 32, nalus: vec![vec![0x40, 1]] }, - HevcNalArray { completeness: true, nal_type: 33, nalus: vec![vec![0x42, 1, 2]] }, - HevcNalArray { completeness: true, nal_type: 34, nalus: vec![vec![0x44, 1]] }, - ], - ..Default::default() - }; - let back = HevcConfig::parse(&cfg.to_bytes()).unwrap(); - assert_eq!(back, cfg); - assert_eq!(back.sps(), vec![&[0x42u8, 1, 2][..]]); - } - - #[test] - fn av1_vp9_opus_roundtrip() { - let a = Av1Config { seq_profile: 1, seq_level_idx_0: 8, high_bitdepth: true, config_obus: vec![1, 2], ..Default::default() }; - assert_eq!(Av1Config::parse(&a.to_bytes()).unwrap(), a); - let v = VpcConfig { - profile: 2, - level: 31, - bit_depth: 10, - chroma_subsampling: 1, - full_range: true, - colour_primaries: 9, - transfer_characteristics: 16, - matrix_coefficients: 9, - codec_init: vec![], - }; - assert_eq!(VpcConfig::parse(&v.to_bytes()).unwrap(), v); - let o = OpusConfig { output_channels: 2, pre_skip: 312, input_sample_rate: 48000, output_gain: 0, channel_mapping_family: 0, channel_mapping: vec![] }; - assert_eq!(OpusConfig::parse(&o.to_bytes()).unwrap(), o); - } - - #[test] - fn aac_channel_configuration_is_an_index() { - // 48 kHz AAC LC: configuration → channels (Table 1.19 with Amd 4; #922) - let channels = |c: u16| AacConfig::from_asc(((2 << 11) | (3 << 7) | (c << 3)).to_be_bytes().to_vec()).channels(); - assert_eq!([0, 1, 6, 7, 8, 11, 12, 13, 14, 15].map(channels), [0, 1, 6, 8, 0, 7, 8, 24, 8, 0]); - } - - #[test] - fn aac_esds_roundtrip() { - let e = SampleEntry::aac(vec![0x12, 0x10], 2, 44100); - match &e.codec { - CodecConfig::Aac(a) => { - assert_eq!((a.object_type, a.sample_rate, a.channel_config), (2, 44100, 2)); - } - _ => panic!(), - } - assert_eq!(roundtrip(&e, false, b"soun"), e); - assert_eq!(roundtrip(&e, true, b"soun"), e); - } - - /// QuickTime's `.mp2` entry (what ffmpeg writes for MP2 in MOV) is MPEG audio, as `.mp3` is, - /// even under a handler that does not say "sound" (#800). - #[test] - fn quicktime_mpeg_audio_entries() { - for fourcc in [*b".mp3", *b".mp2"] { - let e = SampleEntry::audio(FourCc(fourcc), CodecConfig::Mp3, 2, 48000.0, 16); - for handler in [b"soun", b"\0\0\0\0"] { - let back = roundtrip(&e, true, handler); - assert_eq!(back.codec, CodecConfig::Mp3, "{fourcc:?} {handler:?}"); - assert_eq!(back.audio.map(|a| (a.channels, a.sample_rate)), Some((2, 48000.0)), "{fourcc:?} {handler:?}"); - } - } - } - - #[test] - fn pcm_entries() { - for (bits, float, be, qt) in [ - (16, false, false, true), - (16, false, true, true), - (24, false, false, true), - (32, true, false, true), - (24, false, true, false), - (32, true, false, false), - ] { - let p = PcmConfig { bits, float, big_endian: be, signed: true, channels: 2, sample_rate: 48000.0 }; - let e = SampleEntry::pcm(p); - let back = roundtrip(&e, qt, b"soun"); - assert_eq!(back.codec, CodecConfig::Pcm(p), "{bits} {float} {be} {qt}"); - } - } - - #[test] - fn timecode_and_prores() { - let tc = TimecodeConfig { flags: 1, timescale: 30000, frame_duration: 1001, frames_per_second: 30, start_frame: None, name: Some("reel".into()) }; - let e = SampleEntry { - format: FourCc(*b"tmcd"), - data_reference_index: 1, - codec: CodecConfig::Timecode(tc.clone()), - video: None, - audio: None, - bitrate: None, - }; - assert_eq!(roundtrip(&e, true, b"tmcd"), e); - assert_eq!(tc.format_frame(107892), "01:00:00;00"); - let nd = TimecodeConfig { flags: 0, frames_per_second: 24, ..Default::default() }; - assert_eq!(nd.format_frame(86400), "01:00:00:00"); - let p = SampleEntry::prores(FourCc(*b"apch"), 1920, 1080); - assert_eq!(roundtrip(&p, true, b"vide"), p); - } - - #[test] - fn asc_parse() { - assert_eq!(parse_asc(&[0x11, 0x90]), Some((2, 48000, 2))); - assert_eq!(parse_asc(&[]), None); - } -} diff --git a/crates/isobmff/src/demux.rs b/crates/isobmff/src/demux.rs deleted file mode 100644 index c28ba5ccd..000000000 --- a/crates/isobmff/src/demux.rs +++ /dev/null @@ -1,1205 +0,0 @@ -//! Demuxer: parses box structure lazily (headers + `moov`/`moof` only) and builds per-track sample tables. - -use crate::bytes::{Cur, FourCc, boxes, find, parse_box_header}; -use crate::codec::{CodecConfig, SampleEntry, parse_sample_entry}; -use crate::error::{Error, Result, invalid}; -use crate::source::ByteSource; -use std::collections::HashMap; - -/// Hard cap on per-track sample count (after PCM chunk merging). -const MAX_SAMPLES: usize = 1 << 26; -/// Hard cap on the size of an in-memory `moov`/`moof` box. -const MAX_HEADER_BOX: u64 = 1 << 30; - -/// One sample (access unit) of a track, in the track's media timescale. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub struct Sample { - /// Absolute byte offset in the file. - pub offset: u64, - pub size: u32, - /// Decode timestamp (raw media time, edit lists not applied). - pub dts: i64, - /// Presentation timestamp = dts + composition offset (raw media time). - pub pts: i64, - pub duration: u32, - pub is_sync: bool, - /// 1-based index into [`Track::entries`]. - pub description_index: u32, -} - -/// One edit list entry (`elst`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Edit { - /// Duration of this edit in the movie timescale. - pub segment_duration: u64, - /// Start time within the media (track timescale); `-1` marks an empty edit. - pub media_time: i64, - /// Media rate as 16.16 fixed point (0x0001_0000 = normal speed, 0 = dwell). - pub media_rate: i32, -} - -/// Broad classification of a track by its handler. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrackKind { - Video, - Audio, - Timecode, - Subtitle, - Other, -} - -/// A sample-to-group mapping (`sbgp`) with the matching descriptions (`sgpd`) kept raw. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct SampleGroup { - pub grouping_type: FourCc, - /// (sample_count, group_description_index) runs. - pub runs: Vec<(u32, u32)>, - /// Raw description entries from `sgpd`. - pub descriptions: Vec>, -} - -/// A demuxed track. -#[derive(Clone, Debug, PartialEq)] -pub struct Track { - pub id: u32, - pub kind: TrackKind, - pub handler: FourCc, - pub handler_name: String, - /// Media timescale (`mdhd`). - pub timescale: u32, - /// Media duration in the media timescale (`mdhd`). - pub duration: u64, - /// Track duration in the movie timescale (`tkhd`). - pub tkhd_duration: u64, - /// ISO 639-2/T language code (e.g. `und`, `eng`). - pub language: String, - /// `tkhd` presentation size (16.16 fixed point converted to integer pixels, rounded down). - pub width: u32, - pub height: u32, - pub enabled: bool, - pub alternate_group: u16, - /// `tkhd` volume (8.8 fixed point). - pub volume: i16, - /// `tkhd` transformation matrix. - pub matrix: [i32; 9], - pub entries: Vec, - pub samples: Vec, - pub edits: Vec, - /// Offset to add to raw pts to get presentation time (track timescale), derived from the - /// leading empty edits and the first non-empty edit's `media_time`. - pub edit_offset: i64, - /// Track references (`tref`): (type, track ids). - pub references: Vec<(FourCc, Vec)>, - /// Raw `sdtp` bytes (one per sample) if present. - pub sdtp: Vec, - pub sample_groups: Vec, - /// True if uncompressed audio samples were merged into one [`Sample`] per chunk. - pub pcm_chunked: bool, - sync_index: Vec, - pts_order: Vec, -} - -impl Track { - /// The first sample entry's codec configuration. - pub fn codec(&self) -> Option<&CodecConfig> { - self.entries.first().map(|e| &e.codec) - } - pub fn video(&self) -> Option<&crate::codec::VideoParams> { - self.entries.first().and_then(|e| e.video.as_ref()) - } - /// Clockwise quarter turns (0–3) the `tkhd` matrix applies for display, or None when it is not - /// a pure 0/90/180/270° rotation (mirrored, scaled or sheared — left to the caller). - /// - /// ISO/IEC 14496-12 §8.3.2: a sample point (p, q) is displayed at p′ = a·p + c·q + x, - /// q′ = b·p + d·q + y for the matrix {a, b, u, c, d, v, x, y, w}, with y pointing down. So - /// {0, 1, −1, 0} sends the top row to the right-hand column (90° clockwise — iPhone portrait - /// video), {−1, 0, 0, −1} is 180° and {0, −1, 1, 0} is 270°. Only the signs matter: a·d − b·c - /// carries the scale, which the presentation size already reflects. - pub fn display_rotation(&self) -> Option { - let [a, b, _, c, d, _, _, _, _] = self.matrix; - match (a.signum(), b.signum(), c.signum(), d.signum()) { - (1, 0, 0, 1) => Some(0), - (0, 1, -1, 0) => Some(1), - (-1, 0, 0, -1) => Some(2), - (0, -1, 1, 0) => Some(3), - _ => None, - } - } - pub fn audio(&self) -> Option<&crate::codec::AudioParams> { - self.entries.first().and_then(|e| e.audio.as_ref()) - } - /// Presentation (edit-adjusted) pts of sample `i`. - pub fn presentation_pts(&self, i: usize) -> Option { - self.samples.get(i).map(|s| s.pts.saturating_add(self.edit_offset)) - } - /// Index of the sample being presented at raw media time `pts` (the last sample, in - /// presentation order, whose pts is ≤ `pts`; the first sample if `pts` precedes all). - pub fn sample_at_pts(&self, pts: i64) -> Option { - if self.pts_order.is_empty() { - return None; - } - let k = self.pts_order.partition_point(|&i| self.samples[i as usize].pts <= pts); - Some(self.pts_order[k.saturating_sub(1)] as usize) - } - /// Like [`sample_at_pts`](Self::sample_at_pts) but for edit-adjusted presentation time. - pub fn sample_at_presentation_time(&self, t: i64) -> Option { - self.sample_at_pts(t.saturating_sub(self.edit_offset)) - } - /// The closest sync sample at or before `index` (decode order); 0 if there is none. - pub fn sync_sample_before(&self, index: usize) -> usize { - let k = self.sync_index.partition_point(|&i| i as usize <= index); - if k == 0 { 0 } else { self.sync_index[k - 1] as usize } - } - /// Indices of all sync samples. - pub fn sync_samples(&self) -> &[u32] { - &self.sync_index - } - - fn finish_indexes(&mut self) { - self.sync_index = self.samples.iter().enumerate().filter(|(_, s)| s.is_sync).map(|(i, _)| i as u32).collect(); - let mut order: Vec = (0..self.samples.len() as u32).collect(); - order.sort_by_key(|&i| (self.samples[i as usize].pts, i)); - self.pts_order = order; - } -} - -/// Text metadata from `udta` (QuickTime `©xxx` atoms and iTunes-style `meta/ilst`, incl. `keys`). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct Metadata { - pub entries: Vec<(String, String)>, -} - -impl Metadata { - pub fn get(&self, key: &str) -> Option<&str> { - self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) - } - /// Title from `©nam` or the `com.apple.quicktime.title` key. - pub fn title(&self) -> Option<&str> { - self.get("©nam").or_else(|| self.get("com.apple.quicktime.title")).or_else(|| self.get("title")) - } -} - -/// A parsed MP4/MOV file (headers and sample tables; media data stays in the source). -#[derive(Clone, Debug, PartialEq)] -pub struct Mp4File { - pub major_brand: FourCc, - pub minor_version: u32, - pub compatible_brands: Vec, - /// QuickTime file (major brand `qt ` or no `ftyp`). - pub is_quicktime: bool, - /// Movie timescale (`mvhd`). - pub timescale: u32, - /// Movie duration in the movie timescale (`mvhd`; for fragmented files, the longest track). - pub duration: u64, - pub tracks: Vec, - pub metadata: Metadata, - /// File uses movie fragments (`mvex` present). - pub fragmented: bool, - /// Byte offset of the `moov` box. - pub moov_offset: u64, - /// Number of `moof` boxes found. - pub fragment_count: usize, -} - -impl Mp4File { - /// Read the bytes of sample `index` of track `track` (index into [`tracks`](Self::tracks)). - pub fn read_sample(&self, src: &S, track: usize, index: usize) -> Result> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - read_range(src, s.offset, s.size as u64) - } - pub fn sample_at_pts(&self, track: usize, pts: i64) -> Option { - self.tracks.get(track)?.sample_at_pts(pts) - } - pub fn sync_sample_before(&self, track: usize, index: usize) -> Option { - Some(self.tracks.get(track)?.sync_sample_before(index)) - } - /// First track of the given kind. - pub fn track_of_kind(&self, kind: TrackKind) -> Option { - self.tracks.iter().position(|t| t.kind == kind) - } -} - -fn read_range(src: &S, offset: u64, size: u64) -> Result> { - let end = offset.checked_add(size).ok_or(Error::Truncated("sample"))?; - if end > src.len() { - return Err(Error::Truncated("sample data")); - } - let mut v = vec![0u8; size as usize]; - src.read_at(offset, &mut v)?; - Ok(v) -} - -/// Parse an MP4/MOV file. Only box headers, `ftyp`, `moov` and `moof` boxes are read. -pub fn open(src: S) -> Result { - let len = src.len(); - let mut offset = 0u64; - let mut ftyp: Option> = None; - let mut moov: Option<(u64, Vec)> = None; - let mut moofs: Vec<(u64, Vec)> = Vec::new(); - while offset + 8 <= len { - let n = (len - offset).min(32) as usize; - let mut hb = [0u8; 32]; - src.read_at(offset, &mut hb[..n])?; - let h = parse_box_header(&hb[..n])?; - let size = h.size.unwrap_or(len - offset); - let end = offset.saturating_add(size).min(len); - let body_len = end.saturating_sub(offset + h.header_len); - let read_body = |max: u64| -> Result> { - if body_len > max { - return Err(Error::TooLarge("header box")); - } - read_range(&src, offset + h.header_len, body_len) - }; - match &h.kind.0 { - b"ftyp" if ftyp.is_none() => ftyp = Some(read_body(1 << 16)?), - b"moov" if moov.is_none() => moov = Some((offset, read_body(MAX_HEADER_BOX)?)), - b"moof" => moofs.push((offset, read_body(MAX_HEADER_BOX)?)), - _ => {} - } - if h.size.is_none() { - break; - } - offset = offset.saturating_add(size); - } - let (moov_offset, moov) = moov.ok_or(Error::NoMoov)?; - let mut file = Mp4File { - major_brand: FourCc::default(), - minor_version: 0, - compatible_brands: Vec::new(), - is_quicktime: true, - timescale: 0, - duration: 0, - tracks: Vec::new(), - metadata: Metadata::default(), - fragmented: false, - moov_offset, - fragment_count: moofs.len(), - }; - if let Some(f) = &ftyp { - let mut c = Cur::new(f, "ftyp"); - file.major_brand = c.fourcc()?; - file.minor_version = c.u32().unwrap_or(0); - while c.remaining() >= 4 { - file.compatible_brands.push(c.fourcc()?); - } - file.is_quicktime = file.major_brand == b"qt "; - } - let mut trex: HashMap = HashMap::new(); - let mut trak_payloads = Vec::new(); - for b in boxes(&moov) { - let b = b?; - match &b.kind.0 { - b"cmov" => return Err(Error::Unsupported("compressed movie header (cmov)".into())), - b"mvhd" => { - let mut c = Cur::new(b.payload, "mvhd"); - let (v, _) = c.full_header()?; - if v == 1 { - c.skip(16)?; - file.timescale = c.u32()?; - file.duration = c.u64()?; - } else { - c.skip(8)?; - file.timescale = c.u32()?; - file.duration = c.u32()? as u64; - } - } - b"trak" => trak_payloads.push(b.payload), - b"mvex" => { - file.fragmented = true; - for m in boxes(b.payload).map_while(|b| b.ok()) { - if m.kind == b"trex" { - let mut c = Cur::new(m.payload, "trex"); - c.full_header()?; - let id = c.u32()?; - trex.insert(id, Trex { description_index: c.u32()?, duration: c.u32()?, size: c.u32()?, flags: c.u32()? }); - } - } - } - b"udta" => parse_udta(b.payload, &mut file.metadata), - b"meta" => parse_meta(b.payload, &mut file.metadata), - _ => {} - } - } - for p in trak_payloads { - let t = parse_trak(p, &file, len)?; - file.tracks.push(t); - } - // Movie fragments. - let mut next_dts: Vec = file.tracks.iter().map(|t| t.samples.last().map(|s| s.dts + s.duration as i64).unwrap_or(0)).collect(); - for (moof_off, moof) in &moofs { - parse_moof(*moof_off, moof, &mut file.tracks, &trex, &mut next_dts, len)?; - } - for t in &mut file.tracks { - if file.fragmented && !moofs.is_empty() { - let d: u64 = t.samples.iter().map(|s| s.duration as u64).sum(); - t.duration = t.duration.max(d); - if t.timescale > 0 && file.timescale > 0 { - let md = (t.duration as u128 * file.timescale as u128 / t.timescale as u128) as u64; - file.duration = file.duration.max(md); - } - } - t.finish_indexes(); - // Timecode start frame from the first sample. - if let Some(CodecConfig::Timecode(tc)) = t.entries.first_mut().map(|e| &mut e.codec) - && let Some(s) = t.samples.first() - && s.size >= 4 - { - let mut b = [0u8; 4]; - if src.read_at(s.offset, &mut b).is_ok() { - tc.start_frame = Some(u32::from_be_bytes(b)); - } - } - } - Ok(file) -} - -#[derive(Clone, Copy, Debug, Default)] -struct Trex { - description_index: u32, - duration: u32, - size: u32, - flags: u32, -} - -fn parse_language(code: u16) -> String { - if code < 0x400 { - // QuickTime Macintosh language code. - return if code == 0 { "eng".into() } else { "und".into() }; - } - let c = |s: u16| (((code >> s) & 0x1F) as u8 + 0x60) as char; - let s: String = [c(10), c(5), c(0)].into_iter().collect(); - if s.chars().all(|ch| ch.is_ascii_lowercase()) { s } else { "und".into() } -} - -fn rescale(v: i128, from: u32, to: u32) -> i64 { - if from == 0 { - return 0; - } - (v * to as i128 / from as i128).clamp(i64::MIN as i128, i64::MAX as i128) as i64 -} - -fn parse_trak(p: &[u8], file: &Mp4File, file_len: u64) -> Result { - let mut t = Track { - id: 0, - kind: TrackKind::Other, - handler: FourCc::default(), - handler_name: String::new(), - timescale: 0, - duration: 0, - tkhd_duration: 0, - language: "und".into(), - width: 0, - height: 0, - enabled: true, - alternate_group: 0, - volume: 0, - matrix: [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000], - entries: Vec::new(), - samples: Vec::new(), - edits: Vec::new(), - edit_offset: 0, - references: Vec::new(), - sdtp: Vec::new(), - sample_groups: Vec::new(), - pcm_chunked: false, - sync_index: Vec::new(), - pts_order: Vec::new(), - }; - let mut mdia = None; - for b in boxes(p) { - let b = b?; - match &b.kind.0 { - b"tkhd" => { - let mut c = Cur::new(b.payload, "tkhd"); - let (v, flags) = c.full_header()?; - t.enabled = flags & 1 != 0; - if v == 1 { - c.skip(16)?; - t.id = c.u32()?; - c.skip(4)?; - t.tkhd_duration = c.u64()?; - } else { - c.skip(8)?; - t.id = c.u32()?; - c.skip(4)?; - t.tkhd_duration = c.u32()? as u64; - } - c.skip(8)?; - let _layer = c.i16()?; - t.alternate_group = c.u16()?; - t.volume = c.i16()?; - c.skip(2)?; - for m in t.matrix.iter_mut() { - *m = c.i32()?; - } - t.width = c.u32()? >> 16; - t.height = c.u32()? >> 16; - } - b"edts" => { - if let Some(e) = find(b.payload, b"elst") { - let mut c = Cur::new(e, "elst"); - let (v, _) = c.full_header()?; - let n = c.count(if v == 1 { 20 } else { 12 })?; - for _ in 0..n { - let (segment_duration, media_time) = if v == 1 { (c.u64()?, c.i64()?) } else { (c.u32()? as u64, c.i32()? as i64) }; - t.edits.push(Edit { segment_duration, media_time, media_rate: c.i32()? }); - } - } - } - b"tref" => { - for r in boxes(b.payload).map_while(|b| b.ok()) { - let ids = r.payload.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(); - t.references.push((r.kind, ids)); - } - } - b"mdia" => mdia = Some(b.payload), - _ => {} - } - } - let mdia = mdia.ok_or_else(|| Error::Invalid("trak without mdia".into()))?; - let mut minf = None; - for b in boxes(mdia) { - let b = b?; - match &b.kind.0 { - b"mdhd" => { - let mut c = Cur::new(b.payload, "mdhd"); - let (v, _) = c.full_header()?; - if v == 1 { - c.skip(16)?; - t.timescale = c.u32()?; - t.duration = c.u64()?; - } else { - c.skip(8)?; - t.timescale = c.u32()?; - t.duration = c.u32()? as u64; - } - t.language = parse_language(c.u16()?); - } - b"hdlr" => { - let mut c = Cur::new(b.payload, "hdlr"); - c.full_header()?; - let _pre = c.u32()?; - t.handler = c.fourcc()?; - c.skip(12)?; - let name = c.rest(); - // ISO: null-terminated UTF-8; QuickTime: Pascal string. - let s = if !name.is_empty() && name[0] as usize == name.len() - 1 && name.len() > 1 { - &name[1..] - } else { - name.split(|&b| b == 0).next().unwrap_or(&[]) - }; - t.handler_name = String::from_utf8_lossy(s).into_owned(); - } - b"minf" => minf = Some(b.payload), - _ => {} - } - } - t.kind = match &t.handler.0 { - b"vide" => TrackKind::Video, - b"soun" => TrackKind::Audio, - b"tmcd" => TrackKind::Timecode, - b"text" | b"sbtl" | b"subt" | b"clcp" => TrackKind::Subtitle, - _ => TrackKind::Other, - }; - // Edit offset in media timescale. - let mut empty = 0i128; - for e in &t.edits { - if e.media_time == -1 { - empty += e.segment_duration as i128; - } else { - t.edit_offset = rescale(empty, file.timescale, t.timescale).saturating_sub(e.media_time); - break; - } - } - if let Some(stbl) = minf.and_then(|m| find(m, b"stbl")) { - parse_stbl(stbl, &mut t, file.is_quicktime, file_len)?; - } - Ok(t) -} - -struct SttsCursor<'a> { - runs: &'a [(u32, u32)], - run: usize, - used: u32, -} - -impl SttsCursor<'_> { - /// Advance by `n` samples, returning the summed duration. - fn take(&mut self, mut n: u64) -> u64 { - let mut total = 0u64; - while n > 0 && self.run < self.runs.len() { - let (count, delta) = self.runs[self.run]; - let avail = (count - self.used) as u64; - let k = avail.min(n); - total = total.saturating_add(k.saturating_mul(delta as u64)); - n -= k; - self.used += k as u32; - if self.used >= count { - self.run += 1; - self.used = 0; - } - } - total - } - fn next_value(&mut self) -> u32 { - while self.run < self.runs.len() && self.runs[self.run].0 == 0 { - self.run += 1; - } - let Some(&(_, d)) = self.runs.get(self.run) else { return 0 }; - self.take(1); - d - } -} - -fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64) -> Result<()> { - let mut stts: Vec<(u32, u32)> = Vec::new(); - let mut ctts: Vec<(u32, u32)> = Vec::new(); - let mut stss: Option> = None; - let mut stsc: Vec<(u32, u32, u32)> = Vec::new(); - let mut const_size = 0u32; - let mut sample_count = 0usize; - let mut sizes: Vec = Vec::new(); - let mut chunks: Vec = Vec::new(); - let mut sbgp: Vec<(FourCc, Vec<(u32, u32)>)> = Vec::new(); - let mut sgpd: HashMap>> = HashMap::new(); - for b in boxes(stbl) { - let b = b?; - let mut c = Cur::new(b.payload, "stbl child"); - match &b.kind.0 { - b"stsd" => { - c.full_header()?; - let n = c.count(8)?; - for e in boxes(c.rest()).take(n) { - let e = e?; - t.entries.push(parse_sample_entry(e.kind, e.payload, t.handler, is_qt)?); - } - } - b"stts" => { - c.full_header()?; - let n = c.count(8)?; - stts = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - } - b"ctts" => { - c.full_header()?; - let n = c.count(8)?; - ctts = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - } - b"stss" => { - c.full_header()?; - let n = c.count(4)?; - let mut v: Vec = (0..n).map(|_| c.u32()).collect::>()?; - v.sort_unstable(); - stss = Some(v); - } - b"stsc" => { - c.full_header()?; - let n = c.count(12)?; - stsc = (0..n).map(|_| Ok((c.u32()?, c.u32()?, c.u32()?))).collect::>()?; - } - b"stsz" => { - c.full_header()?; - const_size = c.u32()?; - let n = c.u32()? as usize; - if const_size == 0 { - c.check_count(n, 4)?; - sizes = (0..n).map(|_| c.u32()).collect::>()?; - sample_count = n; - } else { - // Samples of constant size must fit in the file: clamp absurd counts. - sample_count = (n as u64).min(file_len / const_size as u64) as usize; - } - } - b"stz2" => { - c.full_header()?; - let field = c.u32()? & 0xFF; - let n = c.u32()? as usize; - let bits = match field { - 4 | 8 | 16 => field as usize, - _ => return invalid(format!("stz2 field size {field}")), - }; - if n.checked_mul(bits).is_none_or(|b| b.div_ceil(8) > c.remaining()) { - return Err(Error::Truncated("stz2")); - } - let d = c.rest(); - sizes = (0..n) - .map(|i| match bits { - 4 => ((d[i / 2] >> if i % 2 == 0 { 4 } else { 0 }) & 0xF) as u32, - 8 => d[i] as u32, - _ => u16::from_be_bytes([d[2 * i], d[2 * i + 1]]) as u32, - }) - .collect(); - sample_count = n; - } - b"stco" => { - c.full_header()?; - let n = c.count(4)?; - chunks = (0..n).map(|_| c.u32().map(|v| v as u64)).collect::>()?; - } - b"co64" => { - c.full_header()?; - let n = c.count(8)?; - chunks = (0..n).map(|_| c.u64()).collect::>()?; - } - b"sdtp" => { - c.full_header()?; - t.sdtp = c.rest().to_vec(); - } - b"sbgp" => { - let (v, _) = c.full_header()?; - let gt = c.fourcc()?; - if v == 1 { - c.u32()?; - } - let n = c.count(8)?; - let runs = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - sbgp.push((gt, runs)); - } - b"sgpd" => { - let (v, _) = c.full_header()?; - let gt = c.fourcc()?; - let default_len = if v >= 1 { c.u32()? } else { 0 }; - if v >= 2 { - c.u32()?; - } - let n = c.count(0)?; - let mut descs = Vec::new(); - for _ in 0..n { - let l = if v >= 1 && default_len == 0 { c.u32()? } else { default_len }; - if l == 0 { - // Version 0 without known length: keep the remainder as a single entry. - descs.push(c.rest().to_vec()); - break; - } - descs.push(c.bytes(l as usize)?.to_vec()); - } - sgpd.insert(gt, descs); - } - _ => {} - } - } - t.sample_groups = sbgp.into_iter().map(|(gt, runs)| SampleGroup { grouping_type: gt, runs, descriptions: sgpd.remove(>).unwrap_or_default() }).collect(); - if sample_count == 0 || chunks.is_empty() || stsc.is_empty() { - return Ok(()); - } - if stsc[0].0 == 0 || stsc.windows(2).any(|w| w[1].0 <= w[0].0) { - return invalid("stsc first_chunk values are not increasing from 1"); - } - let pcm = match t.entries.first().map(|e| &e.codec) { - Some(CodecConfig::Pcm(p)) if const_size != 0 => Some(*p), - _ => None, - }; - let mut stts_c = SttsCursor { runs: &stts, run: 0, used: 0 }; - let mut ctts_c = SttsCursor { runs: &ctts, run: 0, used: 0 }; - let mut samples = Vec::new(); - let mut sidx = 0usize; - let mut dts = 0i64; - let mut stsc_i = 0usize; - if let Some(p) = pcm { - t.pcm_chunked = true; - let a = t.audio().cloned().unwrap_or_default(); - let frame_bytes = |frames: u64| -> u64 { - if a.qt_version == 1 && a.bytes_per_frame > 0 && a.samples_per_packet > 0 { - frames / a.samples_per_packet as u64 * a.bytes_per_frame as u64 - } else if const_size > 1 { - frames * const_size as u64 - } else { - frames * p.bytes_per_frame() as u64 - } - }; - if chunks.len() > MAX_SAMPLES { - return Err(Error::TooLarge("chunk count")); - } - samples.reserve(chunks.len()); - for (ci, &off) in chunks.iter().enumerate() { - while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 { - stsc_i += 1; - } - let spc = (stsc[stsc_i].1 as usize).min(sample_count - sidx); - if spc == 0 { - continue; - } - let dur = stts_c.take(spc as u64); - let size = frame_bytes(spc as u64).min(u32::MAX as u64) as u32; - samples.push(Sample { - offset: off, - size, - dts, - pts: dts, - duration: dur.min(u32::MAX as u64) as u32, - is_sync: true, - description_index: stsc[stsc_i].2, - }); - dts = dts.saturating_add(dur.min(i64::MAX as u64) as i64); - sidx += spc; - if sidx >= sample_count { - break; - } - } - } else { - if sample_count > MAX_SAMPLES { - return Err(Error::TooLarge("sample count")); - } - samples.reserve(sample_count); - let mut stss_i = 0usize; - 'chunks: for (ci, &off) in chunks.iter().enumerate() { - while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 { - stsc_i += 1; - } - let (_, spc, sdi) = stsc[stsc_i]; - let mut pos = off; - for _ in 0..spc { - if sidx >= sample_count { - break 'chunks; - } - let size = if const_size != 0 { const_size } else { sizes[sidx] }; - let duration = stts_c.next_value(); - let cts = ctts_c.next_value() as i32 as i64; - let is_sync = match &stss { - None => true, - Some(v) => { - while stss_i < v.len() && (v[stss_i] as usize) < sidx + 1 { - stss_i += 1; - } - stss_i < v.len() && v[stss_i] as usize == sidx + 1 - } - }; - samples.push(Sample { offset: pos, size, dts, pts: dts.saturating_add(cts), duration, is_sync, description_index: sdi }); - pos = pos.saturating_add(size as u64); - dts = dts.saturating_add(duration as i64); - sidx += 1; - } - } - } - t.samples = samples; - Ok(()) -} - -fn parse_moof(moof_off: u64, moof: &[u8], tracks: &mut [Track], trex: &HashMap, next_dts: &mut [i64], file_len: u64) -> Result<()> { - // End of data of the previous traf, for the base-offset default of subsequent trafs. - let mut prev_end: Option = None; - for b in boxes(moof) { - let b = b?; - if b.kind != b"traf" { - continue; - } - let mut tfhd = None; - let mut tfdt = None; - let mut truns = Vec::new(); - for c in boxes(b.payload) { - let c = c?; - match &c.kind.0 { - b"tfhd" => tfhd = Some(c.payload), - b"tfdt" => tfdt = Some(c.payload), - b"trun" => truns.push(c.payload), - _ => {} - } - } - let tfhd = tfhd.ok_or_else(|| Error::Invalid("traf without tfhd".into()))?; - let mut c = Cur::new(tfhd, "tfhd"); - let (_, fl) = c.full_header()?; - let id = c.u32()?; - let Some(ti) = tracks.iter().position(|t| t.id == id) else { continue }; - let d = trex.get(&id).copied().unwrap_or_default(); - let base = if fl & 1 != 0 { - c.u64()? - } else if fl & 0x20000 != 0 { - moof_off - } else { - prev_end.unwrap_or(moof_off) - }; - let sdi = if fl & 2 != 0 { c.u32()? } else { d.description_index }; - let def_dur = if fl & 8 != 0 { c.u32()? } else { d.duration }; - let def_size = if fl & 0x10 != 0 { c.u32()? } else { d.size }; - let def_flags = if fl & 0x20 != 0 { c.u32()? } else { d.flags }; - if let Some(p) = tfdt { - let mut c = Cur::new(p, "tfdt"); - let (v, _) = c.full_header()?; - let t = if v == 1 { c.u64()? } else { c.u32()? as u64 }; - next_dts[ti] = t.min(i64::MAX as u64) as i64; - } - let mut data_pos = base; - for tr in truns { - let mut c = Cur::new(tr, "trun"); - let (v, tf) = c.full_header()?; - let per = [0x100u32, 0x200, 0x400, 0x800].iter().filter(|&&f| tf & f != 0).count() * 4; - let n = c.u32()? as usize; - if per > 0 { - c.check_count(n, per)?; - } else if (n as u64).saturating_mul(def_size.max(1) as u64) > file_len { - return Err(Error::TooLarge("trun sample count")); - } - if tracks[ti].samples.len() + n > MAX_SAMPLES { - return Err(Error::TooLarge("sample count")); - } - if tf & 1 != 0 { - data_pos = base.wrapping_add_signed(c.i32()? as i64); - } - let first_flags = if tf & 4 != 0 { Some(c.u32()?) } else { None }; - let t = &mut tracks[ti]; - t.samples.reserve(n); - for i in 0..n { - let dur = if tf & 0x100 != 0 { c.u32()? } else { def_dur }; - let size = if tf & 0x200 != 0 { c.u32()? } else { def_size }; - let flags = if tf & 0x400 != 0 { c.u32()? } else { def_flags }; - // first_sample_flags overrides the flags of the first sample only. - let flags = if i == 0 { first_flags.unwrap_or(flags) } else { flags }; - let cto = if tf & 0x800 != 0 { - let raw = c.u32()?; - if v == 0 { raw as i64 } else { raw as i32 as i64 } - } else { - 0 - }; - let dts = next_dts[ti]; - t.samples.push(Sample { - offset: data_pos, - size, - dts, - pts: dts.saturating_add(cto), - duration: dur, - is_sync: flags & 0x1_0000 == 0, - description_index: sdi, - }); - data_pos = data_pos.saturating_add(size as u64); - next_dts[ti] = dts.saturating_add(dur as i64); - } - } - prev_end = Some(data_pos); - } - Ok(()) -} - -fn parse_udta(p: &[u8], md: &mut Metadata) { - for b in boxes(p).map_while(|b| b.ok()) { - if b.kind == b"meta" { - parse_meta(b.payload, md); - } else if b.kind.0[0] == 0xA9 && b.payload.len() >= 4 { - let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; - if let Some(s) = b.payload.get(4..4 + n) { - push_metadata(md, &b.kind.to_string_lossy(), s); - } - } - } -} - -fn parse_meta(p: &[u8], md: &mut Metadata) { - // QuickTime `meta` is a plain box; ISO `meta` is a full box. - let body = if p.len() >= 8 && &p[4..8] == b"hdlr" { p } else { p.get(4..).unwrap_or(&[]) }; - let mut keys: Vec = Vec::new(); - if let Some(k) = find(body, b"keys") { - let mut c = Cur::new(k, "keys"); - if c.full_header().is_ok() - && let Ok(n) = c.count(8) - { - for _ in 0..n { - let Ok(sz) = c.u32() else { break }; - let Ok(_ns) = c.u32() else { break }; - let Ok(name) = c.bytes((sz as usize).saturating_sub(8)) else { break }; - keys.push(String::from_utf8_lossy(name).into_owned()); - } - } - } - let Some(ilst) = find(body, b"ilst") else { return }; - for item in boxes(ilst).map_while(|b| b.ok()) { - let idx = item.kind.as_u32() as usize; - let key = if !keys.is_empty() && idx >= 1 && idx <= keys.len() { - std::borrow::Cow::Borrowed(keys[idx - 1].as_str()) - } else { - std::borrow::Cow::Owned(item.kind.to_string_lossy()) - }; - if let Some(d) = find(item.payload, b"data") - && d.len() >= 8 - { - let ty = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) & 0xFF_FFFF; - if ty == 1 || ty == 0 { - push_metadata(md, &key, &d[8..]); - } - } - } -} - -/// Metadata is untrusted presentation data. Bound its cumulative expanded representation before -/// cloning repeated numeric keys or converting values into owned strings. -const MAX_METADATA_BYTES: usize = 1 << 20; -const MAX_METADATA_ENTRIES: usize = 4_096; - -fn push_metadata(md: &mut Metadata, key: &str, value: &[u8]) { - if md.entries.len() >= MAX_METADATA_ENTRIES { - return; - } - let used = md.entries.iter().map(|(k, v)| k.len().saturating_add(v.len())).sum::(); - // Invalid UTF-8 can expand to one three-byte replacement character per input byte. - let worst = key.len().saturating_add(value.len().saturating_mul(3)); - if used.saturating_add(worst) > MAX_METADATA_BYTES { - return; - } - md.entries.push((key.to_owned(), String::from_utf8_lossy(value).into_owned())); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn language_codes() { - // "und" = 0x55C4 - assert_eq!(parse_language(0x55C4), "und"); - assert_eq!(parse_language(0), "eng"); - assert_eq!(parse_language(0x15C7), "eng"); - } - - #[test] - fn stts_cursor() { - let runs = [(2, 10), (0, 99), (3, 5)]; - let mut c = SttsCursor { runs: &runs, run: 0, used: 0 }; - assert_eq!(c.next_value(), 10); - assert_eq!(c.take(3), 10 + 5 + 5); - assert_eq!(c.next_value(), 5); - assert_eq!(c.next_value(), 0); - } - - #[test] - fn metadata_expansion_is_bounded_before_strings_are_retained() { - let mut md = Metadata::default(); - push_metadata(&mut md, "k", &vec![b'x'; MAX_METADATA_BYTES]); - assert!(md.entries.is_empty()); - for _ in 0..MAX_METADATA_ENTRIES + 10 { - push_metadata(&mut md, "key", b"value"); - } - assert_eq!(md.entries.len(), MAX_METADATA_ENTRIES); - } - - #[test] - fn rejects_no_moov_and_cmov() { - let mut b = crate::bytes::BoxBuf::new(); - b.leaf(b"free", &[0; 4]); - assert!(matches!(open(b.buf.clone()), Err(Error::NoMoov))); - let m = b.start(b"moov"); - b.leaf(b"cmov", &[]); - b.end(m); - assert!(matches!(open(b.buf), Err(Error::Unsupported(_)))); - } - - #[test] - fn metadata_qt_and_ilst() { - let mut b = crate::bytes::BoxBuf::new(); - let m = b.start(&[0xA9, b'n', b'a', b'm']); - b.u16(2); - b.u16(0); - b.bytes(b"hi"); - b.end(m); - let meta = b.start_full(b"meta", 0, 0); - b.leaf(b"hdlr", &[0; 24]); - let il = b.start(b"ilst"); - let it = b.start(&[0xA9, b'A', b'R', b'T']); - let d = b.start(b"data"); - b.u32(1); - b.u32(0); - b.bytes(b"me"); - b.end(d); - b.end(it); - b.end(il); - b.end(meta); - let mut md = Metadata::default(); - parse_udta(&b.buf, &mut md); - assert_eq!(md.title(), Some("hi")); - assert_eq!(md.get("©ART"), Some("me")); - } - - use crate::bytes::BoxBuf; - - fn full(b: &mut BoxBuf, kind: &[u8; 4], v: u8, f: impl FnOnce(&mut BoxBuf)) { - let m = b.start_full(kind, v, 0); - f(b); - b.end(m); - } - - /// Hand-built file: uuid/wide/skip boxes, stz2 sizes, co64, ctts v1 with negative offsets, - /// stss, sdtp, sbgp/sgpd, and an edit list with a leading empty edit. - fn handmade(stsz_override: Option<(u32, u32)>) -> Vec { - let mut b = BoxBuf::new(); - let m = b.start(b"ftyp"); - b.bytes(b"isom"); - b.u32(0); - b.bytes(b"isom"); - b.end(m); - let m = b.start(b"uuid"); - b.bytes(&[0xAB; 16]); - b.bytes(b"payload"); - b.end(m); - b.leaf(b"wide", &[]); - b.leaf(b"skip", &[1, 2, 3]); - // mdat with 64-bit size: 4 samples of 3,1,2,4 bytes. - let data_start = b.len() as u64 + 16; - b.u32(1); - b.bytes(b"mdat"); - b.u64(16 + 10); - b.bytes(&[1, 1, 1, 2, 3, 3, 4, 4, 4, 4]); - let moov = b.start(b"moov"); - full(&mut b, b"mvhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(1000); - b.u32(500); - b.zeros(80); - }); - let trak = b.start(b"trak"); - full(&mut b, b"tkhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(7); - b.u32(0); - b.u32(500); - b.zeros(52); - b.u32(320 << 16); - b.u32(240 << 16); - }); - let edts = b.start(b"edts"); - full(&mut b, b"elst", 1, |b| { - b.u32(2); - b.u64(100); - b.i64(-1); - b.i32(0x10000); - b.u64(400); - b.i64(20); - b.i32(0x10000); - }); - b.end(edts); - let mdia = b.start(b"mdia"); - full(&mut b, b"mdhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(100); - b.u32(40); - b.u16(0x15C7); - b.u16(0); - }); - full(&mut b, b"hdlr", 0, |b| { - b.u32(0); - b.bytes(b"vide"); - b.zeros(12); - b.bytes(b"Video\0"); - }); - let minf = b.start(b"minf"); - let stbl = b.start(b"stbl"); - full(&mut b, b"stsd", 0, |b| { - b.u32(1); - crate::codec::SampleEntry::jpeg(320, 240).write(b, false).unwrap(); - }); - full(&mut b, b"stts", 0, |b| { - b.u32(1); - b.u32(4); - b.u32(10); - }); - full(&mut b, b"ctts", 1, |b| { - b.u32(2); - b.u32(2); - b.i32(-5); - b.u32(2); - b.i32(5); - }); - full(&mut b, b"stss", 0, |b| { - b.u32(2); - b.u32(3); - b.u32(1); - }); - full(&mut b, b"stsc", 0, |b| { - b.u32(2); - b.u32(1); - b.u32(3); - b.u32(1); - b.u32(2); - b.u32(1); - b.u32(1); - }); - match stsz_override { - Some((size, count)) => full(&mut b, b"stsz", 0, |b| { - b.u32(size); - b.u32(count); - }), - None => full(&mut b, b"stz2", 0, |b| { - b.u32(4); - b.u32(4); - b.u8(0x31); - b.u8(0x24); - }), - } - full(&mut b, b"co64", 0, |b| { - b.u32(2); - b.u64(data_start); - b.u64(data_start + 6); - }); - full(&mut b, b"sdtp", 0, |b| b.bytes(&[0x20, 0x10, 0x10, 0x10])); - full(&mut b, b"sbgp", 0, |b| { - b.bytes(b"roll"); - b.u32(1); - b.u32(4); - b.u32(1); - }); - full(&mut b, b"sgpd", 1, |b| { - b.bytes(b"roll"); - b.u32(2); - b.u32(1); - b.i16(-1); - }); - b.end(stbl); - b.end(minf); - b.end(mdia); - b.end(trak); - b.end(moov); - // Trailing box extending to EOF (size 0). - b.u32(0); - b.bytes(b"free"); - b.bytes(&[9; 5]); - b.buf - } - - #[test] - fn handmade_sample_table() { - let d = handmade(None); - let f = open(d.as_slice()).unwrap(); - assert_eq!(f.major_brand, *b"isom"); - let t = &f.tracks[0]; - assert_eq!((t.id, t.kind, t.timescale, t.language.as_str()), (7, TrackKind::Video, 100, "eng")); - assert_eq!((t.width, t.height), (320, 240)); - assert_eq!(t.handler_name, "Video"); - let sizes: Vec = t.samples.iter().map(|s| s.size).collect(); - assert_eq!(sizes, [3, 1, 2, 4]); - let offs: Vec = t.samples.iter().map(|s| s.offset - t.samples[0].offset).collect(); - assert_eq!(offs, [0, 3, 4, 6]); - let pts: Vec = t.samples.iter().map(|s| s.pts).collect(); - assert_eq!(pts, [-5, 5, 25, 35]); - let sync: Vec = t.samples.iter().map(|s| s.is_sync).collect(); - assert_eq!(sync, [true, false, true, false]); - for (i, want) in [vec![1u8, 1, 1], vec![2], vec![3, 3], vec![4; 4]].iter().enumerate() { - assert_eq!(&f.read_sample(d.as_slice(), 0, i).unwrap(), want); - } - // Empty edit of 100 movie ticks = 10 media ticks, then media starts at 20. - assert_eq!(t.edits.len(), 2); - assert_eq!(t.edit_offset, 10 - 20); - assert_eq!(t.presentation_pts(2), Some(15)); - assert_eq!(t.sdtp.len(), 4); - assert_eq!(t.sample_groups[0].grouping_type, *b"roll"); - assert_eq!(t.sample_groups[0].descriptions, vec![vec![0xFF, 0xFF]]); - assert!(f.read_sample(d.as_slice(), 0, 4).is_err()); - assert!(f.read_sample(d.as_slice(), 1, 0).is_err()); - } - - #[test] - fn absurd_counts_are_bounded() { - // Constant-size stsz claiming 4 billion samples is clamped to what fits in the file. - let d = handmade(Some((1, u32::MAX))); - let f = open(d.as_slice()).unwrap(); - assert!(f.tracks[0].samples.len() <= d.len()); - // A table claiming more entries than its box holds fails cleanly. - let mut d = handmade(None); - let p = d.windows(4).position(|w| w == b"stts").unwrap(); - d[p + 8..p + 12].copy_from_slice(&u32::MAX.to_be_bytes()); - assert!(matches!(open(d.as_slice()), Err(Error::Truncated(_)))); - // Box larger than its parent. - let mut d = handmade(None); - let p = d.windows(4).position(|w| w == b"stsc").unwrap(); - d[p - 4..p].copy_from_slice(&0x7FFF_FFFFu32.to_be_bytes()); - assert!(open(d.as_slice()).is_err()); - } -} diff --git a/crates/isobmff/src/error.rs b/crates/isobmff/src/error.rs deleted file mode 100644 index b5dfd752f..000000000 --- a/crates/isobmff/src/error.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::io; - -/// Errors produced by the demuxer and muxer. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("I/O error: {0}")] - Io(#[from] io::Error), - /// A box or structure ended before all required fields were read. - #[error("truncated data while reading {0}")] - Truncated(&'static str), - /// Structurally invalid data. - #[error("invalid data: {0}")] - Invalid(String), - /// Valid but unsupported feature (e.g. compressed `cmov` movie header). - #[error("unsupported: {0}")] - Unsupported(String), - /// A count or size exceeds sanity limits relative to the file size. - #[error("size limit exceeded: {0}")] - TooLarge(&'static str), - /// No `moov` box was found. - #[error("no moov box found")] - NoMoov, - /// A track index was out of range. - #[error("track index {0} out of range")] - NoSuchTrack(usize), - /// A sample index was out of range. - #[error("sample index {0} out of range")] - NoSuchSample(usize), - /// Muxer usage error (e.g. invalid track config, bad sample). - #[error("muxer: {0}")] - Mux(String), -} - -pub type Result = std::result::Result; - -pub(crate) fn invalid(msg: impl Into) -> Result { - Err(Error::Invalid(msg.into())) -} diff --git a/crates/isobmff/src/lib.rs b/crates/isobmff/src/lib.rs index 6c0b657d8..50871ab54 100644 --- a/crates/isobmff/src/lib.rs +++ b/crates/isobmff/src/lib.rs @@ -1,28 +1,7 @@ -//! Clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) support. -//! -//! - **Demux:** [`open`] parses box headers plus `moov` (and `moof` for fragmented files) from a -//! [`ByteSource`] and builds per-track [`Sample`] tables; media data is read on demand with -//! [`Mp4File::read_sample`]. -//! - **Mux:** [`Mp4Writer`] writes progressive MP4/MOV (optionally faststart); -//! [`FragmentedWriter`] writes fragmented MP4. -//! -//! See the crate README for supported boxes and limitations. +//! ISO BMFF (MP4) and QuickTime (MOV) demuxer and muxer: a re-export of +//! [`craft_media::isobmff`] from craft-libs. Supported boxes and limitations are in the +//! craft-media README. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -mod bytes; -mod codec; -mod demux; -mod error; -mod mux; -mod source; - -pub use bytes::FourCc; -pub use codec::{ - AacConfig, ApvConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray, - MasteringDisplay, OpusConfig, PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc, -}; -pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open}; -pub use error::{Error, Result}; -pub use mux::{Brand, FragmentedWriter, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; -pub use source::ByteSource; +pub use craft_media::isobmff::*; diff --git a/crates/isobmff/src/mux.rs b/crates/isobmff/src/mux.rs deleted file mode 100644 index 880d49a2c..000000000 --- a/crates/isobmff/src/mux.rs +++ /dev/null @@ -1,963 +0,0 @@ -//! Muxers: progressive MP4/MOV ([`Mp4Writer`], optional faststart) and fragmented MP4 ([`FragmentedWriter`]). - -use crate::bytes::BoxBuf; -use crate::codec::{CodecConfig, SampleEntry, TimecodeConfig}; -use crate::demux::Edit; -use crate::error::{Error, Result}; -use std::io::{Read, Seek, SeekFrom, Write}; - -/// Output flavour: determines brands and some sample-entry encodings. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Brand { - /// ISO MP4: `isom` major brand, compatible `isom iso2 avc1 mp41 mp42`. - Mp4, - /// QuickTime MOV: `qt ` major brand. - Mov, -} - -/// Movie-level writer options. -#[derive(Clone, Debug)] -pub struct WriterOptions { - pub brand: Brand, - /// Movie timescale (`mvhd`); default 1000. - pub movie_timescale: u32, - /// Text metadata written to `moov/udta` (keys like `©nam`, `©ART`, `©cmt`, `©too`). - pub metadata: Vec<(String, String)>, -} - -impl WriterOptions { - pub fn new(brand: Brand) -> Self { - WriterOptions { brand, movie_timescale: 1000, metadata: Vec::new() } - } -} - -/// Per-track configuration. -#[derive(Clone, Debug)] -pub struct TrackConfig { - pub entry: SampleEntry, - /// Media timescale. For PCM it must equal the sample rate (one tick per audio frame). - pub timescale: u32, - /// ISO 639-2/T language (`und` by default). - pub language: String, - /// Explicit edit list (segment durations in the movie timescale). Takes precedence over `media_start`. - pub edits: Vec, - /// Convenience: create one edit starting at this media time (e.g. AAC encoder delay / priming, - /// or the first pts of a B-frame stream) spanning the rest of the media. - pub media_start: Option, - /// `hdlr` name; a sensible default is used when `None`. - pub handler_name: Option, -} - -impl TrackConfig { - pub fn new(entry: SampleEntry, timescale: u32) -> Self { - TrackConfig { entry, timescale, language: "und".into(), edits: Vec::new(), media_start: None, handler_name: None } - } -} - -/// A sample handed to the muxers. -#[derive(Clone, Copy, Debug)] -pub struct WriteSample<'a> { - pub data: &'a [u8], - /// Duration in the media timescale. Ignored for PCM (each frame lasts one tick). - pub duration: u32, - /// pts − dts in the media timescale. - pub composition_offset: i32, - pub is_sync: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Handler { - Video, - Audio, - Timecode, - Other, -} - -fn handler_of(e: &SampleEntry) -> Handler { - if matches!(e.codec, CodecConfig::Timecode(_)) { - Handler::Timecode - } else if e.video.is_some() { - Handler::Video - } else if e.audio.is_some() { - Handler::Audio - } else { - Handler::Other - } -} - -/// Accumulated sample tables for one track. -#[derive(Clone, Debug, Default)] -struct Tables { - stts: Vec<(u32, u32)>, - ctts: Vec<(u32, i32)>, - sizes: Vec, - /// Constant sample size (PCM) — then `sizes` is unused. - const_size: Option, - sample_count: u64, - /// 1-based sync sample numbers. - sync: Vec, - all_sync: bool, - /// (offset relative to file start before shifting, sample count) - chunks: Vec<(u64, u32)>, - media_duration: u64, - /// End of the last sample on the composition timeline (decode time + composition offset + - /// duration, maximum over samples). - comp_end: u64, -} - -impl Tables { - fn new() -> Self { - Tables { all_sync: true, ..Default::default() } - } - fn push_run(runs: &mut Vec<(u32, T)>, count: u32, v: T) { - if let Some(last) = runs.last_mut() - && last.1 == v - && last.0.checked_add(count).is_some() - { - last.0 += count; - return; - } - runs.push((count, v)); - } -} - -struct WTrack { - cfg: TrackConfig, - id: u32, - handler: Handler, - pcm_frame_bytes: Option, - t: Tables, - /// Track id of a linked timecode track (`tref/tmcd`). - tmcd_ref: Option, - /// For timecode tracks: (video track index, start frame). - tmcd_source: Option<(usize, u32)>, -} - -fn pack_language(lang: &str) -> u16 { - let b = lang.as_bytes(); - if b.len() == 3 && b.iter().all(|c| c.is_ascii_lowercase()) { - (((b[0] - 0x60) as u16) << 10) | (((b[1] - 0x60) as u16) << 5) | ((b[2] - 0x60) as u16) - } else { - 0x55C4 // "und" - } -} - -fn rescale(v: u64, from: u32, to: u32) -> u64 { - if from == 0 { - return 0; - } - (v as u128 * to as u128 / from as u128).min(u64::MAX as u128) as u64 -} - -const MATRIX: [i32; 9] = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000]; - -fn write_ftyp(b: &mut BoxBuf, brand: Brand) { - let m = b.start(b"ftyp"); - match brand { - Brand::Mp4 => { - b.bytes(b"isom"); - b.u32(0x200); - for c in [b"isom", b"iso2", b"avc1", b"mp41", b"mp42"] { - b.bytes(c); - } - } - Brand::Mov => { - b.bytes(b"qt "); - b.u32(0x200); - b.bytes(b"qt "); - } - } - b.end(m); -} - -fn movie_duration(tracks: &[WTrack], movie_ts: u32) -> u64 { - tracks.iter().map(|t| presentation_duration(t, movie_ts)).max().unwrap_or(0) -} - -fn effective_edits(t: &WTrack, movie_ts: u32) -> Vec { - if !t.cfg.edits.is_empty() { - return t.cfg.edits.clone(); - } - if let Some(start) = t.cfg.media_start { - // presentation runs from `start` to the end of the last sample in composition order (with - // B-frame reordering that is past the end of the decode timeline) - let rest = t.t.comp_end.max(t.t.media_duration).saturating_sub(start.max(0) as u64); - return vec![Edit { segment_duration: rescale(rest, t.cfg.timescale, movie_ts), media_time: start, media_rate: 0x10000 }]; - } - Vec::new() -} - -fn presentation_duration(t: &WTrack, movie_ts: u32) -> u64 { - let e = effective_edits(t, movie_ts); - if e.is_empty() { rescale(t.t.media_duration, t.cfg.timescale, movie_ts) } else { e.iter().map(|e| e.segment_duration).sum() } -} - -fn write_mvhd(b: &mut BoxBuf, timescale: u32, duration: u64, next_id: u32) { - let v1 = duration > u32::MAX as u64; - let m = b.start_full(b"mvhd", v1 as u8, 0); - if v1 { - b.u64(0); - b.u64(0); - b.u32(timescale); - b.u64(duration); - } else { - b.u32(0); - b.u32(0); - b.u32(timescale); - b.u32(duration as u32); - } - b.u32(0x10000); - b.u16(0x100); - b.zeros(10); - for v in MATRIX { - b.i32(v); - } - b.zeros(24); - b.u32(next_id); - b.end(m); -} - -fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, force_co64: bool) -> Result<()> { - let trak = b.start(b"trak"); - let dur = presentation_duration(t, movie_ts); - let v1 = dur > u32::MAX as u64; - let m = b.start_full(b"tkhd", v1 as u8, 3); - if v1 { - b.u64(0); - b.u64(0); - b.u32(t.id); - b.u32(0); - b.u64(dur); - } else { - b.u32(0); - b.u32(0); - b.u32(t.id); - b.u32(0); - b.u32(dur as u32); - } - b.zeros(8); - b.i16(0); - b.u16(0); - b.i16(if t.handler == Handler::Audio { 0x100 } else { 0 }); - b.u16(0); - for v in MATRIX { - b.i32(v); - } - let (w, h) = match &t.cfg.entry.video { - Some(v) => { - let w = match v.pixel_aspect { - Some((hs, vs)) if hs > 0 && vs > 0 => (v.width as u64 * hs as u64 / vs as u64) as u32, - _ => v.width as u32, - }; - (w, v.height as u32) - } - None => (0, 0), - }; - b.u32(w << 16); - b.u32(h << 16); - b.end(m); - let edits = effective_edits(t, movie_ts); - if !edits.is_empty() { - let e = b.start(b"edts"); - let v1 = edits.iter().any(|e| e.segment_duration > u32::MAX as u64 || e.media_time > i32::MAX as i64 || e.media_time < i32::MIN as i64); - let l = b.start_full(b"elst", v1 as u8, 0); - b.u32(edits.len() as u32); - for ed in &edits { - if v1 { - b.u64(ed.segment_duration); - b.i64(ed.media_time); - } else { - b.u32(ed.segment_duration as u32); - b.i32(ed.media_time as i32); - } - b.i32(ed.media_rate); - } - b.end(l); - b.end(e); - } - if let Some(id) = t.tmcd_ref { - let r = b.start(b"tref"); - let m = b.start(b"tmcd"); - b.u32(id); - b.end(m); - b.end(r); - } - let mdia = b.start(b"mdia"); - let mv1 = t.t.media_duration > u32::MAX as u64; - let m = b.start_full(b"mdhd", mv1 as u8, 0); - if mv1 { - b.u64(0); - b.u64(0); - b.u32(t.cfg.timescale); - b.u64(t.t.media_duration); - } else { - b.u32(0); - b.u32(0); - b.u32(t.cfg.timescale); - b.u32(t.t.media_duration as u32); - } - b.u16(pack_language(&t.cfg.language)); - b.u16(0); - b.end(m); - let (htype, default_name) = match t.handler { - Handler::Video => (b"vide", "VideoHandler"), - Handler::Audio => (b"soun", "SoundHandler"), - Handler::Timecode => (b"tmcd", "TimeCodeHandler"), - Handler::Other => (b"meta", "MetaHandler"), - }; - let name = t.cfg.handler_name.clone().unwrap_or_else(|| default_name.into()); - let m = b.start_full(b"hdlr", 0, 0); - b.bytes(if qt { b"mhlr" } else { &[0, 0, 0, 0] }); - b.bytes(htype); - b.zeros(12); - if qt { - let n = name.len().min(255); - b.u8(n as u8); - b.bytes(&name.as_bytes()[..n]); - } else { - b.bytes(name.as_bytes()); - b.u8(0); - } - b.end(m); - let minf = b.start(b"minf"); - match t.handler { - Handler::Video => { - let m = b.start_full(b"vmhd", 0, 1); - b.zeros(8); - b.end(m); - } - Handler::Audio => { - let m = b.start_full(b"smhd", 0, 0); - b.zeros(4); - b.end(m); - } - Handler::Timecode => { - let g = b.start(b"gmhd"); - let m = b.start_full(b"gmin", 0, 0); - b.u16(0x40); - b.u16(0x8000); - b.u16(0x8000); - b.u16(0x8000); - b.i16(0); - b.u16(0); - b.end(m); - let tm = b.start(b"tmcd"); - let m = b.start_full(b"tcmi", 0, 0); - b.u16(0); - b.u16(0); - b.u16(12); - b.u16(0); - b.bytes(&[0xFF; 6]); - b.zeros(6); - b.u8(0); - b.end(m); - b.end(tm); - b.end(g); - } - Handler::Other => { - let m = b.start_full(b"nmhd", 0, 0); - b.end(m); - } - } - if qt { - // QuickTime data handler reference. - let m = b.start_full(b"hdlr", 0, 0); - b.bytes(b"dhlr"); - b.bytes(b"url "); - b.zeros(12); - b.u8(0); - b.end(m); - } - let d = b.start(b"dinf"); - let r = b.start_full(b"dref", 0, 0); - b.u32(1); - let u = b.start_full(b"url ", 0, 1); - b.end(u); - b.end(r); - b.end(d); - let stbl = b.start(b"stbl"); - let m = b.start_full(b"stsd", 0, 0); - b.u32(1); - t.cfg.entry.write(b, qt)?; - b.end(m); - let tb = &t.t; - let m = b.start_full(b"stts", 0, 0); - b.u32(tb.stts.len() as u32); - for &(c, d) in &tb.stts { - b.u32(c); - b.u32(d); - } - b.end(m); - if tb.ctts.iter().any(|&(_, o)| o != 0) { - let neg = tb.ctts.iter().any(|&(_, o)| o < 0); - let m = b.start_full(b"ctts", neg as u8, 0); - b.u32(tb.ctts.len() as u32); - for &(c, o) in &tb.ctts { - b.u32(c); - b.i32(o); - } - b.end(m); - } - if !tb.all_sync { - let m = b.start_full(b"stss", 0, 0); - b.u32(tb.sync.len() as u32); - for &s in &tb.sync { - b.u32(s); - } - b.end(m); - } - let m = b.start_full(b"stsc", 0, 0); - let mut runs: Vec<(u32, u32)> = Vec::new(); - for (i, &(_, n)) in tb.chunks.iter().enumerate() { - if runs.last().is_none_or(|r| r.1 != n) { - runs.push((i as u32 + 1, n)); - } - } - b.u32(runs.len() as u32); - for (first, n) in runs { - b.u32(first); - b.u32(n); - b.u32(1); - } - b.end(m); - let m = b.start_full(b"stsz", 0, 0); - match tb.const_size { - Some(s) => { - b.u32(s); - b.u32(tb.sample_count.min(u32::MAX as u64) as u32); - } - None => { - let first = tb.sizes.first().copied().unwrap_or(0); - // A uniform size of 0 would mean "table follows" (ISO/IEC 14496-12 §8.7.3), so - // all-empty samples must still be written as a table. - if first != 0 && !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) { - b.u32(first); - b.u32(tb.sizes.len() as u32); - } else { - b.u32(0); - b.u32(tb.sizes.len() as u32); - for &s in &tb.sizes { - b.u32(s); - } - } - } - } - b.end(m); - let co64 = force_co64 || tb.chunks.iter().any(|&(o, _)| o + shift > u32::MAX as u64); - let m = b.start_full(if co64 { b"co64" } else { b"stco" }, 0, 0); - b.u32(tb.chunks.len() as u32); - for &(o, _) in &tb.chunks { - if co64 { - b.u64(o + shift); - } else { - b.u32((o + shift) as u32); - } - } - b.end(m); - b.end(stbl); - b.end(minf); - b.end(mdia); - b.end(trak); - Ok(()) -} - -fn write_udta(b: &mut BoxBuf, md: &[(String, String)]) { - let items: Vec<([u8; 4], &str)> = md - .iter() - .filter_map(|(k, v)| { - let mut chars = k.chars(); - if chars.next()? != '©' { - return None; - } - let rest: Vec = chars.map(|c| c as u32 as u8).collect(); - (rest.len() == 3).then(|| ([0xA9, rest[0], rest[1], rest[2]], v.as_str())) - }) - .collect(); - if items.is_empty() { - return; - } - let u = b.start(b"udta"); - for (k, v) in items { - let m = b.start(&k); - b.u16(v.len() as u16); - b.u16(0x55C4); - b.bytes(v.as_bytes()); - b.end(m); - } - b.end(u); -} - -fn validate_track(cfg: &TrackConfig) -> Result> { - if cfg.timescale == 0 { - return Err(Error::Mux("timescale must be non-zero".into())); - } - if let CodecConfig::Pcm(p) = &cfg.entry.codec { - if p.bits == 0 || p.channels == 0 { - return Err(Error::Mux("PCM needs bits and channels".into())); - } - if (p.sample_rate - cfg.timescale as f64).abs() > 0.5 { - return Err(Error::Mux("PCM track timescale must equal the sample rate".into())); - } - return Ok(Some(p.bytes_per_frame())); - } - Ok(None) -} - -/// Progressive MP4/MOV writer: `ftyp`, then one `mdat` streamed to `W`, then `moov` at [`finish`](Self::finish) -/// (or moved in front of `mdat` by [`finish_faststart`](Self::finish_faststart)). -pub struct Mp4Writer { - w: W, - opts: WriterOptions, - tracks: Vec, - /// Offset of the placeholder box (8 bytes) preceding the mdat header. - pad_pos: u64, - mdat_pos: u64, - pos: u64, - last_track: Option, -} - -impl Mp4Writer { - /// Start a file: writes `ftyp`, a placeholder box and the `mdat` header. - pub fn new(mut w: W, opts: WriterOptions) -> Result { - let start = w.stream_position()?; - let mut b = BoxBuf::new(); - write_ftyp(&mut b, opts.brand); - let pad_pos = start + b.len() as u64; - b.leaf(if opts.brand == Brand::Mov { b"wide" } else { b"free" }, &[]); - let mdat_pos = start + b.len() as u64; - b.leaf(b"mdat", &[]); - w.write_all(&b.buf)?; - let pos = start + b.len() as u64; - Ok(Mp4Writer { w, opts, tracks: Vec::new(), pad_pos, mdat_pos, pos, last_track: None }) - } - - /// Add a track; returns its index (track ids are index + 1). - pub fn add_track(&mut self, cfg: TrackConfig) -> Result { - let pcm = validate_track(&cfg)?; - let handler = handler_of(&cfg.entry); - let mut t = Tables::new(); - t.const_size = pcm; - self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: pcm, t, cfg, tmcd_ref: None, tmcd_source: None }); - Ok(self.tracks.len() - 1) - } - - /// Replace a track's sample entry before the file is finished (the `moov` is written last), for - /// codecs whose configuration is only complete at the end, like the FLAC STREAMINFO in `dfLa`. - pub fn set_sample_entry(&mut self, track: usize, entry: SampleEntry) -> Result<()> { - let t = self.tracks.get_mut(track).ok_or_else(|| Error::Invalid(format!("no track {track}")))?; - t.cfg.entry = entry; - Ok(()) - } - - /// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start - /// frame number) is written at finish, spanning the video track's duration. - pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result { - if video_track >= self.tracks.len() { - return Err(Error::NoSuchTrack(video_track)); - } - if tc.timescale == 0 || tc.frame_duration == 0 || tc.frames_per_second == 0 { - return Err(Error::Mux("timecode needs timescale, frame duration and fps".into())); - } - let entry = SampleEntry { - format: crate::bytes::FourCc(*b"tmcd"), - data_reference_index: 1, - codec: CodecConfig::Timecode(tc.clone()), - video: None, - audio: None, - bitrate: None, - }; - let idx = self.add_track(TrackConfig::new(entry, tc.timescale))?; - self.tracks[idx].tmcd_source = Some((video_track, start_frame)); - let id = self.tracks[idx].id; - self.tracks[video_track].tmcd_ref = Some(id); - Ok(idx) - } - - /// Append one sample to track `track` (decode order). For PCM tracks `data` may hold any whole - /// number of audio frames. - pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> { - let tr = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - if tr.tmcd_source.is_some() { - return Err(Error::Mux("timecode track samples are written automatically".into())); - } - let (count, dur) = match tr.pcm_frame_bytes { - Some(fb) => { - if !s.data.len().is_multiple_of(fb as usize) { - return Err(Error::Mux("PCM data is not a whole number of frames".into())); - } - ((s.data.len() / fb as usize) as u32, 1u32) - } - None => { - if s.data.len() > u32::MAX as usize { - return Err(Error::Mux("sample larger than 4 GiB".into())); - } - (1, s.duration) - } - }; - if count == 0 { - return Ok(()); - } - self.w.write_all(s.data)?; - let offset = self.pos; - self.pos += s.data.len() as u64; - let contiguous = self.last_track == Some(track); - self.last_track = Some(track); - let tr = &mut self.tracks[track]; - let t = &mut tr.t; - Tables::push_run(&mut t.stts, count, dur); - Tables::push_run(&mut t.ctts, count, if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset }); - if tr.pcm_frame_bytes.is_none() { - t.sizes.push(s.data.len() as u32); - if s.is_sync { - t.sync.push(t.sample_count as u32 + 1); - } else { - t.all_sync = false; - } - } - t.sample_count += count as u64; - let ct = if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset as i64 }; - t.comp_end = t.comp_end.max((t.media_duration as i64 + ct + count as i64 * dur as i64).max(0) as u64); - t.media_duration += count as u64 * dur as u64; - match t.chunks.last_mut() { - Some(c) if contiguous => c.1 += count, - _ => t.chunks.push((offset, count)), - } - Ok(()) - } - - fn finish_mdat(&mut self) -> Result<()> { - // Timecode samples. - for i in 0..self.tracks.len() { - let Some((vi, start)) = self.tracks[i].tmcd_source else { continue }; - let v = &self.tracks[vi]; - let vd = v.t.media_duration; - let vts = v.cfg.timescale; - let tts = self.tracks[i].cfg.timescale; - let tc_dur = match &self.tracks[i].cfg.entry.codec { - CodecConfig::Timecode(tc) => tc.frame_duration, - _ => 1, - }; - let d = rescale(vd, vts, tts).max(tc_dur as u64).min(u32::MAX as u64) as u32; - self.w.write_all(&start.to_be_bytes())?; - let off = self.pos; - self.pos += 4; - self.last_track = None; - let t = &mut self.tracks[i].t; - t.stts.push((1, d)); - t.sizes.push(4); - t.sync.push(1); - t.sample_count = 1; - t.media_duration = d as u64; - t.chunks.push((off, 1)); - } - let size = self.pos - self.mdat_pos; - if size <= u32::MAX as u64 { - self.w.seek(SeekFrom::Start(self.mdat_pos))?; - self.w.write_all(&(size as u32).to_be_bytes())?; - } else { - // Merge the placeholder into a 16-byte large-size mdat header. - let mut h = [0u8; 16]; - h[..4].copy_from_slice(&1u32.to_be_bytes()); - h[4..8].copy_from_slice(b"mdat"); - h[8..].copy_from_slice(&(self.pos - self.pad_pos).to_be_bytes()); - self.w.seek(SeekFrom::Start(self.pad_pos))?; - self.w.write_all(&h)?; - } - self.w.seek(SeekFrom::Start(self.pos))?; - Ok(()) - } - - fn build_moov(&self, shift: u64, force_co64: bool) -> Result> { - let qt = self.opts.brand == Brand::Mov; - let ts = self.opts.movie_timescale.max(1); - let mut b = BoxBuf::new(); - let m = b.start(b"moov"); - write_mvhd(&mut b, ts, movie_duration(&self.tracks, ts), self.tracks.len() as u32 + 1); - for t in &self.tracks { - write_trak(&mut b, t, ts, qt, shift, force_co64)?; - } - write_udta(&mut b, &self.opts.metadata); - b.end(m); - Ok(b.buf) - } - - /// Finish the file with `moov` after `mdat`. Returns the underlying writer. - pub fn finish(mut self) -> Result { - self.finish_mdat()?; - let moov = self.build_moov(0, false)?; - self.w.write_all(&moov)?; - self.w.flush()?; - Ok(self.w) - } -} - -impl Mp4Writer { - /// Finish with `moov` in front of `mdat` ("fast start"): media data is shifted forward in place. - pub fn finish_faststart(mut self) -> Result { - self.finish_mdat()?; - let mut moov = self.build_moov(0, false)?; - let mut force = false; - loop { - let shift = moov.len() as u64; - let next = self.build_moov(shift, force)?; - if next.len() == moov.len() { - moov = next; - break; - } - // Offsets crossed the 32-bit boundary: switch to co64 (size only grows, so this converges). - force = true; - moov = next; - } - let shift = moov.len() as u64; - let start = self.pad_pos; - let end = self.pos; - let mut buf = vec![0u8; 1 << 20]; - let mut cur = end; - while cur > start { - let n = (cur - start).min(buf.len() as u64) as usize; - cur -= n as u64; - self.w.seek(SeekFrom::Start(cur))?; - self.w.read_exact(&mut buf[..n])?; - self.w.seek(SeekFrom::Start(cur + shift))?; - self.w.write_all(&buf[..n])?; - } - self.w.seek(SeekFrom::Start(start))?; - self.w.write_all(&moov)?; - self.w.seek(SeekFrom::Start(end + shift))?; - self.w.flush()?; - Ok(self.w) - } -} - -/// Buffered fragment sample: (data, duration, composition offset, sync). -type PendingSample = (Vec, u32, i32, bool); - -/// Fragmented MP4 writer: an init segment (`ftyp` + `moov` with `mvex`) followed by -/// `moof`+`mdat` fragments. Samples are buffered until [`flush_fragment`](Self::flush_fragment). -pub struct FragmentedWriter { - w: W, - opts: WriterOptions, - tracks: Vec, - pending: Vec>, - next_dts: Vec, - seq: u32, - init_written: bool, -} - -impl FragmentedWriter { - pub fn new(w: W, opts: WriterOptions) -> Self { - FragmentedWriter { w, opts, tracks: Vec::new(), pending: Vec::new(), next_dts: Vec::new(), seq: 0, init_written: false } - } - - /// Add a track (before the first fragment). PCM is not supported in fragments. - pub fn add_track(&mut self, cfg: TrackConfig) -> Result { - if self.init_written { - return Err(Error::Mux("tracks must be added before the first fragment".into())); - } - if validate_track(&cfg)?.is_some() { - return Err(Error::Mux("PCM is not supported by the fragmented writer".into())); - } - let handler = handler_of(&cfg.entry); - self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: None, t: Tables::new(), cfg, tmcd_ref: None, tmcd_source: None }); - self.pending.push(Vec::new()); - self.next_dts.push(0); - Ok(self.tracks.len() - 1) - } - - fn write_init(&mut self) -> Result<()> { - let qt = self.opts.brand == Brand::Mov; - let ts = self.opts.movie_timescale.max(1); - let mut b = BoxBuf::new(); - write_ftyp(&mut b, self.opts.brand); - let m = b.start(b"moov"); - write_mvhd(&mut b, ts, 0, self.tracks.len() as u32 + 1); - for t in &self.tracks { - write_trak(&mut b, t, ts, qt, 0, false)?; - } - let mv = b.start(b"mvex"); - for t in &self.tracks { - let x = b.start_full(b"trex", 0, 0); - b.u32(t.id); - b.u32(1); - b.u32(0); - b.u32(0); - b.u32(0); - b.end(x); - } - b.end(mv); - write_udta(&mut b, &self.opts.metadata); - b.end(m); - self.w.write_all(&b.buf)?; - self.init_written = true; - Ok(()) - } - - /// Buffer a sample for the next fragment. - pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> { - let p = self.pending.get_mut(track).ok_or(Error::NoSuchTrack(track))?; - if s.data.len() > u32::MAX as usize { - return Err(Error::Mux("sample larger than 4 GiB".into())); - } - p.push((s.data.to_vec(), s.duration, s.composition_offset, s.is_sync)); - Ok(()) - } - - fn build_moof(&self, data_offsets: &[i32]) -> Vec { - let mut b = BoxBuf::new(); - let m = b.start(b"moof"); - let h = b.start_full(b"mfhd", 0, 0); - b.u32(self.seq); - b.end(h); - for (i, t) in self.tracks.iter().enumerate() { - let p = &self.pending[i]; - if p.is_empty() { - continue; - } - let tf = b.start(b"traf"); - let h = b.start_full(b"tfhd", 0, 0x20000); - b.u32(t.id); - b.end(h); - let h = b.start_full(b"tfdt", 1, 0); - b.u64(self.next_dts[i]); - b.end(h); - let neg = p.iter().any(|s| s.2 < 0); - let h = b.start_full(b"trun", neg as u8, 0x1 | 0x100 | 0x200 | 0x400 | 0x800); - b.u32(p.len() as u32); - b.i32(data_offsets[i]); - for (d, dur, cto, sync) in p { - b.u32(*dur); - b.u32(d.len() as u32); - b.u32(if *sync { 0x0200_0000 } else { 0x0101_0000 }); - b.i32(*cto); - } - b.end(h); - b.end(tf); - } - b.end(m); - b.buf - } - - /// Write all buffered samples as one `moof` + `mdat` fragment (no-op if nothing is buffered). - pub fn flush_fragment(&mut self) -> Result<()> { - if !self.init_written { - self.write_init()?; - } - if self.pending.iter().all(|p| p.is_empty()) { - return Ok(()); - } - self.seq += 1; - let probe = self.build_moof(&vec![0; self.tracks.len()]); - let data_len: u64 = self.pending.iter().flatten().map(|s| s.0.len() as u64).sum(); - let large = data_len + 8 > u32::MAX as u64; - let hdr = if large { 16 } else { 8 }; - let mut offs = Vec::with_capacity(self.tracks.len()); - let mut acc = probe.len() as u64 + hdr; - for p in &self.pending { - offs.push(i32::try_from(acc).map_err(|_| Error::Mux("fragment too large".into()))?); - acc += p.iter().map(|s| s.0.len() as u64).sum::(); - } - let moof = self.build_moof(&offs); - self.w.write_all(&moof)?; - if large { - self.w.write_all(&1u32.to_be_bytes())?; - self.w.write_all(b"mdat")?; - self.w.write_all(&(data_len + 16).to_be_bytes())?; - } else { - self.w.write_all(&((data_len + 8) as u32).to_be_bytes())?; - self.w.write_all(b"mdat")?; - } - for (i, p) in self.pending.iter_mut().enumerate() { - for (d, dur, _, _) in p.drain(..) { - self.w.write_all(&d)?; - self.next_dts[i] += dur as u64; - } - } - Ok(()) - } - - /// Flush remaining samples and return the writer. - pub fn finish(mut self) -> Result { - self.flush_fragment()?; - self.w.flush()?; - Ok(self.w) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codec::{AvcConfig, PcmConfig}; - use std::io::Cursor; - - fn avc_entry() -> SampleEntry { - SampleEntry::avc(AvcConfig::new(vec![vec![0x67, 0x42, 0, 0x1E]], vec![vec![0x68, 0xCE]], 4), 64, 48) - } - - #[test] - fn language_pack() { - assert_eq!(pack_language("und"), 0x55C4); - assert_eq!(pack_language("eng"), 0x15C7); - assert_eq!(pack_language("EN"), 0x55C4); - } - - #[test] - fn simple_roundtrip_and_faststart() { - for fast in [false, true] { - let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); - let v = w.add_track(TrackConfig::new(avc_entry(), 12800)).unwrap(); - let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 2, sample_rate: 48000.0 }; - let a = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap(); - for i in 0..10u8 { - let data = vec![i; 100 + i as usize]; - w.write_sample(v, WriteSample { data: &data, duration: 512, composition_offset: if i % 2 == 0 { 1024 } else { 0 }, is_sync: i % 5 == 0 }) - .unwrap(); - w.write_sample(a, WriteSample { data: &[i; 400], duration: 0, composition_offset: 0, is_sync: true }).unwrap(); - } - let out = if fast { w.finish_faststart() } else { w.finish() }.unwrap().into_inner(); - let f = crate::open(out.as_slice()).unwrap(); - assert_eq!(f.tracks.len(), 2); - let t = &f.tracks[0]; - assert_eq!(t.samples.len(), 10); - for (i, s) in t.samples.iter().enumerate() { - assert_eq!(s.size as usize, 100 + i); - assert_eq!(s.dts, 512 * i as i64); - assert_eq!(s.is_sync, i % 5 == 0); - assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 100 + i]); - } - let at = &f.tracks[1]; - assert!(at.pcm_chunked); - assert_eq!(at.samples.len(), 10); - assert_eq!(at.samples[3].size, 400); - assert_eq!(at.samples[3].duration, 100); - assert_eq!(f.read_sample(out.as_slice(), 1, 3).unwrap(), vec![3u8; 400]); - if fast { - assert!(f.moov_offset < t.samples[0].offset); - } - } - } - - #[test] - fn fragmented_roundtrip() { - let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4)); - let v = w.add_track(TrackConfig::new(avc_entry(), 1000)).unwrap(); - for i in 0..6u8 { - w.write_sample(v, WriteSample { data: &[i; 10], duration: 40, composition_offset: -(i as i32 % 2), is_sync: i % 3 == 0 }).unwrap(); - if i % 3 == 2 { - w.flush_fragment().unwrap(); - } - } - let out = w.finish().unwrap(); - let f = crate::open(out.as_slice()).unwrap(); - assert!(f.fragmented); - assert_eq!(f.fragment_count, 2); - let t = &f.tracks[0]; - assert_eq!(t.samples.len(), 6); - for (i, s) in t.samples.iter().enumerate() { - assert_eq!(s.dts, 40 * i as i64); - assert_eq!(s.pts, s.dts - (i as i64 % 2)); - assert_eq!(s.is_sync, i % 3 == 0); - assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 10]); - } - assert_eq!(t.sync_sample_before(5), 3); - } -} diff --git a/crates/isobmff/src/source.rs b/crates/isobmff/src/source.rs deleted file mode 100644 index 0dad82b20..000000000 --- a/crates/isobmff/src/source.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Random-access byte sources for the demuxer. - -use std::io; -use std::sync::Arc; - -/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). -/// -/// Implementations must be able to serve reads at arbitrary offsets through `&self`. -pub trait ByteSource { - /// Total length in bytes. - fn len(&self) -> u64; - /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - /// True if the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -fn eof() -> io::Error { - io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") -} - -impl ByteSource for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let start = usize::try_from(offset).map_err(|_| eof())?; - let end = start.checked_add(buf.len()).ok_or_else(eof)?; - let src = self.get(start..end).ok_or_else(eof)?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ByteSource for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ByteSource for &T { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Arc { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Box { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl ByteSource for std::fs::File { - fn len(&self) -> u64 { - self.metadata().map(|m| m.len()).unwrap_or(0) - } - #[cfg(unix)] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) - } - #[cfg(windows)] - fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { - use std::os::windows::fs::FileExt; - while !buf.is_empty() { - let n = self.seek_read(buf, offset)?; - if n == 0 { - return Err(eof()); - } - offset += n as u64; - buf = &mut buf[n..]; - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - use std::io::{Read, Seek, SeekFrom}; - let mut f = self; - f.seek(SeekFrom::Start(offset))?; - f.read_exact(buf) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_reads() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(1, &mut b).unwrap(); - assert_eq!(b, [2, 3]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let a: Arc<[u8]> = Arc::from(v.clone()); - a.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - assert_eq!(ByteSource::len(&a), 4); - let s: &[u8] = &v; - assert_eq!(ByteSource::len(&s), 4); - } -} diff --git a/crates/matroska/Cargo.toml b/crates/matroska/Cargo.toml index 9f64162fb..1cebf9d31 100644 --- a/crates/matroska/Cargo.toml +++ b/crates/matroska/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-media = { workspace = true, features = ["matroska"] } [dev-dependencies] filmcraft-testkit = { workspace = true } diff --git a/crates/matroska/README.md b/crates/matroska/README.md index 40210ad0a..47b9cb623 100644 --- a/crates/matroska/README.md +++ b/crates/matroska/README.md @@ -1,115 +1,3 @@ # filmcraft-matroska -A clean-room Matroska (MKV) and WebM demuxer, plus a minimal muxer, written from the public -specifications: RFC 8794 (EBML), RFC 9559 (Matroska), the Matroska codec mappings and the WebM -container guidelines. Layer L0: it depends only on `std`, has no `unsafe`, and builds for -`wasm32-unknown-unknown`. No GPL/LGPL code (FFmpeg, libmatroska, mkvtoolnix) was consulted; -FFmpeg is used only as an external test oracle. - -## Demuxing - -```rust -use filmcraft_matroska::{Demuxer, TrackKind, open}; - -// Sample tables, same shape as filmcraft-isobmff (any ByteSource: &[u8], Vec, Arc<[u8]>, File…) -let file = std::fs::File::open("clip.mkv")?; -let mkv = open(&file)?; -let v = mkv.track_of_kind(TrackKind::Video).unwrap(); -let k = mkv.keyframe_before(v, 2_000_000_000).unwrap(); // sample index -let frame = mkv.read_sample(&file, v, k)?; - -// Streaming packets + seeking (from a ByteSource, any Read + Seek, or a byte slice) -let mut d = Demuxer::from_reader(std::io::BufReader::new(std::fs::File::open("clip.webm")?))?; -let sp = d.seek(v, 2_000_000_000)?; // lands on the keyframe with pts ≤ 2 s -while let Some(p) = d.next_packet()? { // file order, all tracks - // p.track, p.pts (ticks), p.pts_ns, p.duration, p.keyframe, p.data -} -``` - -- **Open** (`open`, `open_with(src, &OpenOptions { index, verify_crc })`): EBML header (DocType - `matroska`/`webm`), the first Segment, and its level-1 elements: SeekHead (followed for elements - after the clusters), Info, Tracks, Cues, Chapters, Tags, Attachments (listed; payload located by - offset/size, never read). With `index: true` (default) every Cluster is scanned — element and block - headers only, never frame payloads — to build per-track `Sample` tables (`offset`, `size`, `pts`, - `duration`, `keyframe`, cluster, block offset, lace index) and keyframe lists. With `index: false` - open stops at the first Cluster. -- **Timestamps.** Matroska ticks are `TimestampScale` ns (usually 1 ms); `Track::timebase` is that - as a reduced fraction of a second. `pts` is in ticks, `pts_ns` in exact nanoseconds. `CodecDelay` - is subtracted (RFC 9559), as FFmpeg does (Opus/Vorbis priming gives negative pts). Laced frames get - `block ts + i × (BlockDuration or DefaultDuration)/n`; if neither is known, later laces repeat the - block timestamp and have `duration == 0` (`Packet::lace` tells which). -- **Keyframes:** SimpleBlock keyframe flag; BlockGroup = keyframe iff it has no ReferenceBlock. -- **Lacing:** Xiph, fixed-size, EBML. **Unknown sizes:** Segment (live/streamed output) and - Clusters end at the next top-level element. **CRC-32:** optional verification of level-1 - elements and clusters; mismatches go to `MkvFile::crc_errors` (not fatal). **Void** skipped. -- **Damage:** undecodable/oversized elements trigger a byte scan for the next plausible level-1 - element (Cluster whose first child is Timestamp, or a sized metadata element); a truncated final - block is dropped. Problems are listed in `MkvFile::warnings`. -- **Seeking** (`Demuxer::seek(track, time_ns)`): uses the sample index when present; otherwise Cues - (refined by scanning the cued cluster for the exact keyframe); otherwise builds the index by - scanning all clusters. `Demuxer::keyframe_index(track)` returns the keyframe list. -- **Tracks:** number/UID/type/flags, `DefaultDuration`, name, language (+BCP 47), CodecID, - CodecPrivate, CodecName, `CodecDelay`, `SeekPreRoll`; `VideoInfo` (pixel/display size, crop, - interlacing, stereo, alpha mode, `pixel_aspect()`), `Colour` (matrix, range, transfer, primaries, - subsampling/siting, MaxCLL/MaxFALL, SMPTE 2086 mastering metadata); `AudioInfo` (rate, output - rate, channels, bit depth); `ContentEncoding`s. -- **Content encodings:** header stripping is undone in every packet / `read_sample`. zlib/bzlib/ - lzo compression and encryption are reported (`Track::frames_readable() == false`); packets are - then returned as stored and `read_sample` fails with `Unsupported`. - -### Codec mapping (`Codec`) - -| CodecID | `Codec` | payload | -|---|---|---| -| `V_MPEG4/ISO/AVC` | `Avc` | avcC (CodecPrivate) | -| `V_MPEGH/ISO/HEVC` | `Hevc` | hvcC | -| `V_VP8`, `V_VP9` | `Vp8`, `Vp9` | VP9 CodecPrivate features | -| `V_AV1` | `Av1` | av1C | -| `V_PRORES` | `ProRes` | FourCC; frames get the 8-byte `size`+`icpf` header re-added | -| `V_MJPEG`, `V_MS/VFW/FOURCC` (MJPG) | `Mjpeg` | | -| `V_MS/VFW/FOURCC` (other) | `VfwFourcc` | BITMAPINFOHEADER | -| `A_AAC`, legacy `A_AAC/MPEG{2,4}/…` | `Aac` | AudioSpecificConfig (synthesised for legacy IDs, incl. SBR) | -| `A_OPUS` | `Opus` | OpusHead | -| `A_VORBIS` | `Vorbis` | three headers split from Xiph-laced CodecPrivate | -| `A_FLAC` | `Flac` | `fLaC` + metadata blocks | -| `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE` | `Pcm` | bit depth from Audio | -| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2`, `A_MPEG/L1` | `Ac3`, `Eac3`, `Mp3`, `Mp2`, `Mp1` | | -| `S_TEXT/UTF8` | `SubRip` | | -| `S_TEXT/WEBVTT`, `D_WEBVTT/*` (WebM) | `WebVtt` | WebM blocks keep `id\nsettings\npayload` | -| `S_TEXT/ASS`, `S_TEXT/SSA` | `Ass` | script header | -| anything else | `Other(id)` | `Track::codec_private` | - -`Codec::name()` matches FFmpeg's `codec_name` for these. - -## Muxing (minimal) - -`MkvWriter::new(w: Write + Seek, Vec, MuxOptions)` → `write_frame(track, pts_ns, -keyframe, data, duration_ns)` → `finish()`. Writes the EBML header, Segment, SeekHead, Info (with -Duration), Tracks, Clusters of SimpleBlocks (new cluster at a video keyframe after -`cluster_duration_ns`), BlockGroups for explicit durations, optional audio lacing (Xiph, EBML, -fixed-size), and Cues (video keyframes, or the first frame of each track per cluster). No -CRC-32, Chapters, Tags, Attachments, or content encodings. - -## Tests - -- Unit: EBML VINTs (known/unknown sizes, IDs, signed lace deltas), typed values, CRC-32, all lacing - modes, codec mapping, a hand-built file (unknown-size Segment/Clusters, header stripping, - BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32 pass/fail, Cue-less - seek), and mux → demux round trips for every lacing mode. -- Oracle (`tests/oracle.rs`, skipped without ffmpeg/ffprobe; fixtures in `target/fixtures/matroska`): - H.264+AAC, VP9+Opus WebM, FLAC, Vorbis, SRT+ASS subtitles, WebVTT WebM, Cues at front, a - streamed (pipe) file without Cues, HEVC with HDR colour, ProRes+PCM, MJPEG+AC-3, AV1. Every packet - is compared with `ffprobe -show_packets` (stream, size, keyframe flag, pts, duration), plus codec - names, time bases, dimensions, audio params and duration; the sample index must equal the packet - stream. Seeks (indexed, Cues-only, and index-on-demand) must land on the latest keyframe ≤ target. - Our muxer's laced output (Xiph/EBML/fixed) is read back by ffprobe and compared the same way. - A damaged file must resync and deliver the packets after the damage; truncated files must not fail. - -## Limitations - -- Only the first Segment is read (no linked/chained segments, no ordered-chapter editions playback). -- zlib/bzlib/lzo compressed and encrypted tracks are detected but their frames are not decoded. -- BlockAdditions (e.g. VP9 alpha, WebVTT settings in Matroska) are skipped; `EncryptedBlock` ignored. -- `TrackTimestampScale` (deprecated) is ignored. -- Lace timestamps without any duration information repeat the block timestamp (FFmpeg derives them - from codec parsers). +A re-export of `craft_media::matroska`. Documentation: https://github.com/storytold/craft-libs/blob/main/crates/media/README.md diff --git a/crates/matroska/src/codec.rs b/crates/matroska/src/codec.rs deleted file mode 100644 index 8d83a974b..000000000 --- a/crates/matroska/src/codec.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Matroska CodecID → codec mapping (Matroska codec specifications, RFC 9559 §5.1.4.1.21 and the -//! codec mappings registry). - -/// Codec of a track, derived from `CodecID` and `CodecPrivate`. -/// -/// Configuration records are passed through as the raw bytes found in `CodecPrivate`, which for -/// the ISO codecs are exactly the ISOBMFF configuration boxes' payloads (avcC, hvcC, av1C, ASC…). -#[derive(Clone, Debug, PartialEq)] -pub enum Codec { - /// `V_MPEG4/ISO/AVC`; payload is an `AVCDecoderConfigurationRecord` (avcC). - Avc { avcc: Vec }, - /// `V_MPEGH/ISO/HEVC`; payload is an `HEVCDecoderConfigurationRecord` (hvcC). - Hevc { hvcc: Vec }, - /// `V_VP8`. - Vp8, - /// `V_VP9`; `CodecPrivate` (optional) holds VP9 codec feature metadata. - Vp9 { private: Vec }, - /// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C). - Av1 { av1c: Vec }, - /// `V_APV`; payload is an `APVDecoderConfigurationRecord` (apvC), if present. - Apv { apvc: Vec }, - /// `V_PRORES`; `fourcc` from `CodecPrivate` (e.g. `apcn`), if present. - ProRes { fourcc: Option<[u8; 4]> }, - /// `V_MJPEG`, or `V_MS/VFW/FOURCC` with an `MJPG` compression FourCC. - Mjpeg, - /// `V_MS/VFW/FOURCC` with any other FourCC; `bitmap_info_header` is the whole `CodecPrivate`. - VfwFourcc { fourcc: [u8; 4], bitmap_info_header: Vec }, - /// `A_AAC` (and the legacy `A_AAC/MPEG{2,4}/` IDs); `asc` is the - /// `AudioSpecificConfig` (synthesised for legacy IDs without `CodecPrivate`). - Aac { asc: Vec }, - /// `A_OPUS`; `head` is the `OpusHead` identification header. - Opus { head: Vec }, - /// `A_VORBIS`; the three Vorbis headers (identification, comment, setup), split from the - /// Xiph-laced `CodecPrivate`. - Vorbis { headers: Vec> }, - /// `A_FLAC`; `private` is `fLaC` + metadata blocks (STREAMINFO first). - Flac { private: Vec }, - /// `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE`. Bit depth comes from the Audio element. - Pcm { float: bool, big_endian: bool, bits: u16 }, - /// `A_AC3`. - Ac3, - /// `A_EAC3`. - Eac3, - /// `A_MPEG/L3`. - Mp3, - /// `A_MPEG/L2`. - Mp2, - /// `A_MPEG/L1`. - Mp1, - /// `S_TEXT/UTF8` (SubRip-style plain text). - SubRip, - /// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`. - WebVtt, - /// `S_TEXT/ASS` / `S_TEXT/SSA`; `header` is the script header from `CodecPrivate`. - Ass { header: Vec, ssa: bool }, - /// Any other CodecID (kept verbatim along with `Track::codec_private`). - Other(String), -} - -impl Codec { - /// Short codec name, matching FFmpeg's `codec_name` where one exists (useful for logs/UI). - pub fn name(&self) -> String { - match self { - Codec::Avc { .. } => "h264".into(), - Codec::Hevc { .. } => "hevc".into(), - Codec::Vp8 => "vp8".into(), - Codec::Vp9 { .. } => "vp9".into(), - Codec::Av1 { .. } => "av1".into(), - Codec::Apv { .. } => "apv".into(), - Codec::ProRes { .. } => "prores".into(), - Codec::Mjpeg => "mjpeg".into(), - Codec::VfwFourcc { fourcc, .. } => String::from_utf8_lossy(fourcc).trim().to_ascii_lowercase(), - Codec::Aac { .. } => "aac".into(), - Codec::Opus { .. } => "opus".into(), - Codec::Vorbis { .. } => "vorbis".into(), - Codec::Flac { .. } => "flac".into(), - Codec::Pcm { float, big_endian, bits } => { - let k = if *float { - 'f' - } else if *bits == 8 { - 'u' - } else { - 's' - }; - if *bits == 8 { format!("pcm_{k}8") } else { format!("pcm_{k}{bits}{}", if *big_endian { "be" } else { "le" }) } - } - Codec::Ac3 => "ac3".into(), - Codec::Eac3 => "eac3".into(), - Codec::Mp3 => "mp3".into(), - Codec::Mp2 => "mp2".into(), - Codec::Mp1 => "mp1".into(), - Codec::SubRip => "subrip".into(), - Codec::WebVtt => "webvtt".into(), - Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(), - Codec::Other(id) => id.clone(), - } - } -} - -const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; - -/// Build a 2-byte (or 5-byte with SBR extension) AudioSpecificConfig for legacy `A_AAC/...` IDs. -fn synth_asc(object_type: u8, rate: f64, out_rate: Option, channels: u64, sbr: bool) -> Vec { - let rate_index = |r: f64| AAC_RATES.iter().position(|&x| x as f64 == r).unwrap_or(4) as u8; - let ri = rate_index(rate); - let ch = channels.min(7) as u8; - let mut v = vec![(object_type << 3) | (ri >> 1), ((ri & 1) << 7) | (ch << 3)]; - if sbr { - // backward-compatible explicit signalling: sync 0x2b7, SBR object type 5, flag, ext rate index - let ext = rate_index(out_rate.unwrap_or(rate * 2.0)); - v.push(0x56); - v.push(0xE5); - v.push(0x80 | (ext << 3)); - } - v -} - -/// Split a Xiph-laced `CodecPrivate` (Vorbis/Theora headers). -fn split_xiph(p: &[u8]) -> Vec> { - let Some(&n) = p.first() else { return Vec::new() }; - let mut pos = 1; - let mut sizes = Vec::new(); - for _ in 0..n { - let mut s = 0usize; - loop { - let Some(&b) = p.get(pos) else { return Vec::new() }; - pos += 1; - s += b as usize; - if b != 255 { - break; - } - } - sizes.push(s); - } - let mut out = Vec::new(); - for s in sizes { - let Some(h) = p.get(pos..pos + s) else { return Vec::new() }; - out.push(h.to_vec()); - pos += s; - } - out.push(p[pos.min(p.len())..].to_vec()); - out -} - -/// Map a CodecID + CodecPrivate (+ audio params for legacy AAC / PCM) to a [`Codec`]. -pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInfo>) -> Codec { - let p = private.to_vec(); - match id { - "V_MPEG4/ISO/AVC" => Codec::Avc { avcc: p }, - "V_MPEGH/ISO/HEVC" => Codec::Hevc { hvcc: p }, - "V_VP8" => Codec::Vp8, - "V_VP9" => Codec::Vp9 { private: p }, - "V_AV1" => Codec::Av1 { av1c: p }, - "V_APV" => Codec::Apv { apvc: p }, - "V_PRORES" => Codec::ProRes { fourcc: private.get(..4).and_then(|f| f.try_into().ok()) }, - "V_MJPEG" => Codec::Mjpeg, - "V_MS/VFW/FOURCC" => { - let fourcc: [u8; 4] = private.get(16..20).and_then(|f| f.try_into().ok()).unwrap_or(*b" "); - match &fourcc { - b"MJPG" | b"mjpg" | b"AVRn" | b"AVDJ" => Codec::Mjpeg, - b"H264" | b"h264" | b"avc1" | b"X264" | b"x264" => Codec::Avc { avcc: Vec::new() }, - b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => Codec::ProRes { fourcc: Some(fourcc) }, - b"apv1" | b"APV1" => Codec::Apv { apvc: Vec::new() }, - _ => Codec::VfwFourcc { fourcc, bitmap_info_header: p }, - } - } - "A_AAC" => Codec::Aac { asc: p }, - "A_OPUS" => Codec::Opus { head: p }, - "A_VORBIS" => Codec::Vorbis { headers: split_xiph(private) }, - "A_FLAC" => Codec::Flac { private: p }, - "A_PCM/INT/LIT" | "A_PCM/INT/BIG" | "A_PCM/FLOAT/IEEE" => { - let bits = audio.and_then(|a| a.bit_depth).unwrap_or(if id == "A_PCM/FLOAT/IEEE" { 32 } else { 16 }) as u16; - Codec::Pcm { float: id == "A_PCM/FLOAT/IEEE", big_endian: id == "A_PCM/INT/BIG", bits } - } - "A_AC3" | "A_AC3/BSID9" | "A_AC3/BSID10" => Codec::Ac3, - "A_EAC3" => Codec::Eac3, - "A_MPEG/L3" => Codec::Mp3, - "A_MPEG/L2" => Codec::Mp2, - "A_MPEG/L1" => Codec::Mp1, - "S_TEXT/UTF8" => Codec::SubRip, - "S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt, - "S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false }, - "S_TEXT/SSA" | "S_SSA" => Codec::Ass { header: p, ssa: true }, - _ if id.starts_with("A_AAC/") => { - if !private.is_empty() { - return Codec::Aac { asc: p }; - } - let (rate, out_rate, ch) = audio.map_or((48000.0, None, 2), |a| (a.sampling_frequency, a.output_sampling_frequency, a.channels)); - let object_type = if id.ends_with("/MAIN") { - 1 - } else if id.ends_with("/SSR") { - 3 - } else if id.ends_with("/LTP") { - 4 - } else { - 2 - }; - let sbr = id.ends_with("/SBR"); - Codec::Aac { asc: synth_asc(object_type, rate, out_rate, ch, sbr) } - } - _ => Codec::Other(id.to_string()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mapping() { - assert_eq!(map_codec("V_MPEG4/ISO/AVC", &[1, 2], None), Codec::Avc { avcc: vec![1, 2] }); - assert_eq!(map_codec("V_APV", &[1, 2, 3], None), Codec::Apv { apvc: vec![1, 2, 3] }); - assert_eq!(map_codec("V_APV", &[], None).name(), "apv"); - assert_eq!(map_codec("V_PRORES", b"apcn", None), Codec::ProRes { fourcc: Some(*b"apcn") }); - let mut bih = vec![0u8; 40]; - bih[16..20].copy_from_slice(b"MJPG"); - assert_eq!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::Mjpeg); - bih[16..20].copy_from_slice(b"FFV1"); - assert!(matches!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::VfwFourcc { fourcc, .. } if &fourcc == b"FFV1")); - let a = crate::AudioInfo { sampling_frequency: 44100.0, channels: 2, bit_depth: Some(24), output_sampling_frequency: None }; - assert_eq!(map_codec("A_PCM/INT/LIT", &[], Some(&a)).name(), "pcm_s24le"); - assert_eq!(map_codec("A_PCM/FLOAT/IEEE", &[], Some(&a)), Codec::Pcm { float: true, big_endian: false, bits: 24 }); - // legacy AAC LC 44.1k stereo → 0x12 0x10 - assert_eq!(map_codec("A_AAC/MPEG4/LC", &[], Some(&a)), Codec::Aac { asc: vec![0x12, 0x10] }); - assert_eq!(map_codec("S_TEXT/UTF8", &[], None).name(), "subrip"); - assert_eq!(map_codec("X_WHATEVER", &[], None), Codec::Other("X_WHATEVER".into())); - } - - #[test] - fn xiph_split() { - let mut p = vec![2u8, 3, 2]; - p.extend_from_slice(&[1, 1, 1, 2, 2, 3, 3, 3, 3]); - let h = split_xiph(&p); - assert_eq!(h, vec![vec![1, 1, 1], vec![2, 2], vec![3, 3, 3, 3]]); - } -} diff --git a/crates/matroska/src/demux.rs b/crates/matroska/src/demux.rs deleted file mode 100644 index 4278a1b83..000000000 --- a/crates/matroska/src/demux.rs +++ /dev/null @@ -1,1219 +0,0 @@ -//! Matroska / WebM demuxer: EBML header, Segment walk, cluster scanning, packet reading, seeking. - -use std::collections::{HashSet, VecDeque}; - -use crate::ebml::{self, Children, Lacing, Size, parse_lacing, read_id, read_size}; -use crate::error::{Error, Result}; -use crate::ids::*; -use crate::meta::*; -use crate::source::{ByteSource, ReadSeekSource}; -use crate::track::{Sample, Track, TrackKind, gcd}; - -const WIN: usize = 64 * 1024; -/// Upper bound for level-1 metadata elements read into memory (Info, Tracks, Cues, Tags…). -const MAX_META: u64 = 256 << 20; - -/// Options for [`open_with`] / [`Demuxer::with_options`]. -#[derive(Clone, Debug)] -pub struct OpenOptions { - /// Scan every cluster at open to build per-track sample tables and keyframe indices - /// (reads only element and block headers, never frame payloads). Default `true`. - /// - /// With `false`, open stops at the first Cluster and uses the SeekHead to find Cues, Tags, - /// Chapters… placed after the clusters; [`Demuxer::seek`] then uses Cues (or builds the index - /// on first use if there are none). - pub index: bool, - /// Verify CRC-32 elements of level-1 elements (and clusters while indexing). Mismatches are - /// recorded in [`MkvFile::crc_errors`]; parsing continues. Default `false`. - pub verify_crc: bool, -} - -impl Default for OpenOptions { - fn default() -> Self { - Self { index: true, verify_crc: false } - } -} - -/// A parsed Matroska / WebM file (first Segment). -#[derive(Clone, Debug)] -pub struct MkvFile { - /// EBML `DocType`: `matroska` or `webm`. - pub doc_type: String, - pub doc_type_version: u64, - pub doc_type_read_version: u64, - /// Absolute offset of the Segment's data (base for SeekHead and Cues positions). - pub segment_data_start: u64, - /// Absolute end of the Segment (file end for unknown-size / live segments). - pub segment_end: u64, - /// Segment size field was "unknown" (live / streamed output). - pub live: bool, - pub info: SegmentInfo, - pub tracks: Vec, - pub seek_head: Vec, - /// Cue points, sorted by time. - pub cues: Vec, - /// Clusters in file order (complete only when [`MkvFile::indexed`]). - pub clusters: Vec, - pub chapters: Vec, - pub tags: Vec, - pub attachments: Vec, - /// Absolute offset of the first Cluster. - pub first_cluster: Option, - /// Sample tables have been built by scanning all clusters. - pub indexed: bool, - /// Number of CRC-32 elements verified (with `verify_crc`). - pub crc_checked: u32, - /// Absolute offsets of elements whose CRC-32 did not match. - pub crc_errors: Vec, - /// Non-fatal problems met while parsing (damage, resyncs, truncation). - pub warnings: Vec, -} - -/// One demuxed frame. -#[derive(Clone, Debug, PartialEq)] -pub struct Packet { - /// Index into [`MkvFile::tracks`]. - pub track: usize, - /// Matroska `TrackNumber`. - pub track_number: u64, - /// Presentation timestamp in timestamp ticks (the track timebase, [`Track::timebase`]). - pub pts: i64, - /// Presentation timestamp in nanoseconds. - pub pts_ns: i64, - /// Duration in ticks (0 if unknown). - pub duration: u64, - /// Duration in nanoseconds (0 if unknown). - pub duration_ns: u64, - pub keyframe: bool, - pub discardable: bool, - pub invisible: bool, - /// `DiscardPadding` (ns) from the enclosing BlockGroup. - pub discard_padding_ns: Option, - /// Absolute offset of the stored frame data. - pub offset: u64, - /// Index of the frame within a laced block (0 when not laced). Laced frames after the first - /// only have exact timestamps when the block or track carries a duration; otherwise they - /// repeat the block timestamp and `duration` is 0. - pub lace: u16, - /// Frame bytes with header stripping undone. Frames of tracks with other content encodings - /// (zlib, encryption) are returned as stored; see [`Track::frames_readable`]. - pub data: Vec, -} - -/// Result of [`Demuxer::seek`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SeekPoint { - pub track: usize, - /// Keyframe timestamp in ticks and ns. - pub pts: i64, - pub pts_ns: i64, - /// Sample index of the keyframe when the file is indexed. - pub sample: Option, - pub cluster_offset: u64, - pub block_offset: u64, -} - -/// A keyframe index entry ([`Demuxer::keyframe_index`]). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Keyframe { - pub sample: usize, - pub pts: i64, - pub pts_ns: i64, - pub block_offset: u64, -} - -// --------------------------------------------------------------------------------------------- -// I/O with a small read window -// --------------------------------------------------------------------------------------------- - -#[derive(Default, Clone, Debug)] -struct Cache { - buf: Vec, - start: u64, -} - -struct Io<'a, S: ByteSource + ?Sized> { - src: &'a S, - len: u64, - cache: &'a mut Cache, -} - -#[derive(Clone, Copy, Debug)] -struct Hdr { - id: u32, - pos: u64, - size: Size, - hlen: usize, -} - -impl Hdr { - fn data_start(&self) -> u64 { - self.pos + self.hlen as u64 - } - /// End offset, clamped to `limit`; unknown sizes run to `limit`. - fn end(&self, limit: u64) -> u64 { - match self.size { - Size::Known(n) => self.data_start().saturating_add(n).min(limit), - Size::Unknown => limit, - } - } - fn overruns(&self, limit: u64) -> bool { - matches!(self.size, Size::Known(n) if self.data_start().saturating_add(n) > limit) - } -} - -impl<'a, S: ByteSource + ?Sized> Io<'a, S> { - fn new(src: &'a S, cache: &'a mut Cache) -> Self { - let len = src.len(); - Self { src, len, cache } - } - - /// Up to `n` (≤ WIN) bytes at `pos` (fewer at EOF). - fn peek(&mut self, pos: u64, n: usize) -> Result<&[u8]> { - let n = n.min(WIN); - if pos >= self.len { - return Ok(&[]); - } - let want_end = (pos + n as u64).min(self.len); - let c = &*self.cache; - if !(pos >= c.start && want_end <= c.start + c.buf.len() as u64) { - let fill = ((self.len - pos) as usize).min(WIN); - let mut buf = std::mem::take(&mut self.cache.buf); - buf.resize(fill, 0); - self.src.read_at(pos, &mut buf)?; - self.cache.buf = buf; - self.cache.start = pos; - } - let o = (pos - self.cache.start) as usize; - Ok(&self.cache.buf[o..o + (want_end - pos) as usize]) - } - - /// Exactly `n` bytes at `pos`. - fn read_vec(&mut self, pos: u64, n: u64) -> Result> { - if pos.checked_add(n).is_none_or(|e| e > self.len) { - return Err(Error::Io(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file"))); - } - if n as usize <= WIN / 2 { - return Ok(self.peek(pos, n as usize)?.to_vec()); - } - let mut v = vec![0u8; n as usize]; - self.src.read_at(pos, &mut v)?; - Ok(v) - } - - /// Element header at `pos` (`None` if undecodable or past `limit`). - fn header(&mut self, pos: u64, limit: u64) -> Result> { - if pos >= limit { - return Ok(None); - } - let b = self.peek(pos, 12)?; - let Some((id, il)) = read_id(b) else { return Ok(None) }; - let Some((size, sl)) = read_size(&b[il..]) else { return Ok(None) }; - let hlen = il + sl; - if pos + hlen as u64 > limit { - return Ok(None); - } - Ok(Some(Hdr { id, pos, size, hlen })) - } - - /// Scan forward from `from` for a plausible level-1 element (Cluster preferred), up to `limit`. - fn resync(&mut self, from: u64, limit: u64) -> Result> { - let mut pos = from; - while pos + 4 <= limit { - let chunk = self.peek(pos, WIN)?.to_vec(); - if chunk.len() < 4 { - return Ok(None); - } - for i in 0..chunk.len() - 3 { - if chunk[i] & 0xF0 != 0x10 { - continue; - } - let id = u32::from_be_bytes([chunk[i], chunk[i + 1], chunk[i + 2], chunk[i + 3]]); - if !is_top_level(id) || id == EBML || id == SEGMENT { - continue; - } - let p = pos + i as u64; - if self.plausible(p, id, limit)? { - return Ok(Some(p)); - } - } - pos += (chunk.len() - 3) as u64; - } - Ok(None) - } - - fn plausible(&mut self, p: u64, id: u32, limit: u64) -> Result { - let Some(h) = self.header(p, limit)? else { return Ok(false) }; - if h.id != id || h.overruns(limit) { - return Ok(false); - } - if h.size == Size::Unknown && id != CLUSTER { - return Ok(false); - } - // first child must decode and fit; clusters must start with Timestamp (after CRC-32/Void) - let mut cp = h.data_start(); - let end = h.end(limit); - for _ in 0..3 { - let Some(c) = self.header(cp, end)? else { return Ok(false) }; - if c.overruns(end) { - return Ok(false); - } - if id != CLUSTER { - return Ok(true); - } - match c.id { - TIMESTAMP => return Ok(true), - CRC32 | VOID => cp = c.end(end), - _ => return Ok(false), - } - } - Ok(false) - } - - /// End of an unknown-size master element: the first child that is a top-level ID or undecodable. - fn unknown_end(&mut self, data_start: u64, limit: u64) -> Result { - let mut pos = data_start; - while pos < limit { - let Some(h) = self.header(pos, limit)? else { return Ok(pos) }; - if is_top_level(h.id) || h.size == Size::Unknown { - return Ok(pos); - } - pos = h.end(limit); - } - Ok(limit) - } -} - -// --------------------------------------------------------------------------------------------- -// Blocks -// --------------------------------------------------------------------------------------------- - -#[derive(Clone, Debug)] -struct BlockRef { - track: usize, - block_offset: u64, - /// Timestamp in ticks (cluster + relative). - ts: i64, - /// Absolute (offset, size) of each frame. - frames: Vec<(u64, u32)>, - block_duration: Option, - keyframe: bool, - discardable: bool, - invisible: bool, - discard_padding: Option, - /// (offset, size) of the `BlockAdditional` with `BlockAddID` 1 (WebM: the VP8/VP9 alpha - /// layer), kept for unlaced blocks only. - addition: Option<(u64, u32)>, -} - -#[derive(Clone, Copy, Debug)] -struct FrameRef { - track: usize, - offset: u64, - size: u32, - pts: i64, - pts_ns: i64, - duration: u64, - duration_ns: u64, - keyframe: bool, - discardable: bool, - invisible: bool, - discard_padding: Option, - block_offset: u64, - lace: u16, - addition: Option<(u64, u32)>, -} - -fn track_lookup(tracks: &[Track]) -> impl Fn(u64) -> Option + '_ { - move |n| tracks.iter().position(|t| t.number == n) -} - -/// Parse the Block/SimpleBlock header at `data_start` (`len` bytes). -/// Returns (track index, relative ts, flags, frames) or `None` for unknown tracks / damage. -fn parse_block( - io: &mut Io<'_, S>, - data_start: u64, - len: u64, - lookup: &dyn Fn(u64) -> Option, -) -> Result)>> { - let head = io.peek(data_start, 16.min(len as usize))?; - let Some((tn, tl, _)) = ebml::read_vint(head) else { return Ok(None) }; - if head.len() < tl + 3 { - return Ok(None); - } - let rel = i16::from_be_bytes([head[tl], head[tl + 1]]); - let flags = head[tl + 2]; - let Some(track) = lookup(tn) else { return Ok(None) }; - let hdr = (tl + 3) as u64; - let lacing = Lacing::from_flags(flags); - let frames = if lacing == Lacing::None { - vec![(data_start + hdr, (len - hdr) as u32)] - } else { - let body = io.read_vec(data_start + hdr, len - hdr)?; - match parse_lacing(lacing, &body) { - Ok(f) => f.into_iter().map(|(o, l)| (data_start + hdr + o as u64, l as u32)).collect(), - Err(_) => return Ok(None), - } - }; - Ok(Some((track, rel, flags, frames))) -} - -/// The `BlockAdditional` with `BlockAddID` 1 (the default ID) in a `BlockAdditions` element: -/// (offset, size) of its data. Other IDs, empty additions and damaged children are skipped. -fn block_addition(io: &mut Io<'_, S>, start: u64, end: u64) -> Result> { - let mut pos = start; - while pos < end { - let Some(more) = io.header(pos, end)? else { break }; - let me = more.end(end); - if more.id == BLOCK_MORE { - let (mut id, mut data) = (1u64, None); - let mut p = more.data_start(); - while p < me { - let Some(c) = io.header(p, me)? else { break }; - let ce = c.end(me); - let len = ce.saturating_sub(c.data_start()); - match c.id { - BLOCK_ADD_ID => id = ebml::uint(io.peek(c.data_start(), len.min(8) as usize)?), - BLOCK_ADDITIONAL => data = u32::try_from(len).ok().filter(|&n| n > 0).map(|n| (c.data_start(), n)), - _ => {} - } - p = ce; - } - if id == 1 && data.is_some() { - return Ok(data); - } - } - pos = me; - } - Ok(None) -} - -/// Parse one SimpleBlock or BlockGroup element. -fn parse_block_elem( - io: &mut Io<'_, S>, - h: &Hdr, - end: u64, - cluster_ts: i64, - lookup: &dyn Fn(u64) -> Option, -) -> Result> { - let ds = h.data_start(); - match h.id { - SIMPLE_BLOCK => { - let Some((track, rel, flags, frames)) = parse_block(io, ds, end - ds, lookup)? else { return Ok(None) }; - Ok(Some(BlockRef { - track, - block_offset: h.pos, - ts: cluster_ts + rel as i64, - frames, - block_duration: None, - keyframe: flags & 0x80 != 0, - discardable: flags & 0x01 != 0, - invisible: flags & 0x08 != 0, - discard_padding: None, - addition: None, - })) - } - BLOCK_GROUP => { - let mut pos = ds; - let mut block = None; - let (mut dur, mut has_ref, mut pad, mut addition) = (None, false, None, None); - while pos < end { - let Some(c) = io.header(pos, end)? else { break }; - let ce = c.end(end); - match c.id { - BLOCK => block = parse_block(io, c.data_start(), ce - c.data_start(), lookup)?, - BLOCK_ADDITIONS => addition = block_addition(io, c.data_start(), ce)?, - BLOCK_DURATION => dur = Some(ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)), - REFERENCE_BLOCK => has_ref = true, - DISCARD_PADDING => pad = Some(ebml::int(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)), - _ => {} - } - pos = ce; - } - let Some((track, rel, flags, frames)) = block else { return Ok(None) }; - // with lacing, an addition belongs to the block, not to one of its frames - let addition = addition.filter(|_| frames.len() == 1); - Ok(Some(BlockRef { - track, - block_offset: h.pos, - ts: cluster_ts + rel as i64, - frames, - block_duration: dur, - keyframe: !has_ref, - discardable: false, - invisible: flags & 0x08 != 0, - discard_padding: pad, - addition, - })) - } - _ => Ok(None), - } -} - -/// Expand a block into frames with per-lace timestamps. -fn expand(b: &BlockRef, t: &Track, scale: u64, skip: u16, out: &mut impl Extend) { - let n = b.frames.len() as u64; - // total block duration in ns - let block_ns: Option = match (b.block_duration, t.default_duration_ns) { - (Some(d), _) => Some(d as u128 * scale as u128), - (None, Some(dd)) => Some(dd as u128 * n as u128), - _ => None, - }; - let scale_i = scale as i128; - // round half away from zero - let to_ticks = |ns: i128| -> i64 { (if ns >= 0 { (ns + scale_i / 2) / scale_i } else { (ns - scale_i / 2) / scale_i }) as i64 }; - // CodecDelay is subtracted from every timestamp (RFC 9559 §5.1.4.1.25); in ticks it is rounded - // once, so tick timestamps stay on the block grid. - let delay_ns = t.codec_delay_ns as i128; - let delay_ticks = to_ticks(delay_ns); - let base_ns = b.ts as i128 * scale_i; - out.extend(b.frames.iter().enumerate().skip(skip as usize).map(|(i, &(offset, size))| { - let (start, end) = match block_ns { - Some(total) => ((total * i as u128 / n as u128) as i128, Some((total * (i as u128 + 1) / n as u128) as i128)), - None => (0, None), - }; - let pts_ns = base_ns + start - delay_ns; - let pts = to_ticks(base_ns + start) - delay_ticks; - let duration = end.map_or(0, |e| (to_ticks(e) - to_ticks(start)).max(0) as u64); - FrameRef { - track: b.track, - offset, - size, - pts, - pts_ns: pts_ns as i64, - duration, - duration_ns: end.map_or(0, |e| (e - start).max(0) as u64), - keyframe: b.keyframe, - discardable: b.discardable, - invisible: b.invisible, - discard_padding: b.discard_padding, - block_offset: b.block_offset, - lace: i as u16, - addition: b.addition, - } - })); -} - -/// Scan a whole cluster (headers only), returning its info and blocks. -fn scan_cluster( - io: &mut Io<'_, S>, - h: &Hdr, - limit: u64, - tracks: &[Track], - warnings: &mut Vec, -) -> Result<(ClusterInfo, Vec)> { - let lookup = track_lookup(tracks); - let ds = h.data_start(); - let known_end = match h.size { - Size::Known(_) => Some(h.end(limit)), - Size::Unknown => None, - }; - let lim = known_end.unwrap_or(limit); - let mut ts: i64 = 0; - let mut blocks = Vec::new(); - let mut pos = ds; - let end = loop { - if pos >= lim { - break lim; - } - let Some(c) = io.header(pos, lim)? else { - warnings.push(format!("damaged cluster at {}: undecodable element at {pos}", h.pos)); - break io.resync(pos + 1, limit)?.unwrap_or(limit); - }; - if known_end.is_none() && is_top_level(c.id) { - break pos; - } - if c.size == Size::Unknown || c.overruns(lim) { - warnings.push(format!("damaged cluster at {}: element 0x{:X} at {pos} overruns", h.pos, c.id)); - if c.overruns(lim) && matches!(c.id, SIMPLE_BLOCK | BLOCK_GROUP) && io.len <= c.data_start() + c.size.known().unwrap_or(0) { - break lim; // truncated file: drop the partial block - } - break io.resync(pos + 1, limit)?.unwrap_or(limit); - } - let ce = c.end(lim); - match c.id { - TIMESTAMP => ts = ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?) as i64, - SIMPLE_BLOCK | BLOCK_GROUP => { - if let Some(b) = parse_block_elem(io, &c, ce, ts, &lookup)? { - blocks.push(b); - } - } - _ => {} - } - pos = ce; - }; - Ok((ClusterInfo { offset: h.pos, data_start: ds, end, timestamp: ts.max(0) as u64 }, blocks)) -} - -// --------------------------------------------------------------------------------------------- -// Open -// --------------------------------------------------------------------------------------------- - -/// Open a Matroska/WebM file with default options (full sample index). -pub fn open(src: S) -> Result { - open_with(src, &OpenOptions::default()) -} - -/// Open with explicit options. -pub fn open_with(src: S, opts: &OpenOptions) -> Result { - let mut cache = Cache::default(); - open_impl(&src, opts, &mut cache) -} - -fn open_impl(src: &S, opts: &OpenOptions, cache: &mut Cache) -> Result { - let mut io = Io::new(src, cache); - let len = io.len; - // EBML header (tolerate leading junk in the first 64 KiB) - let start = { - let b = io.peek(0, WIN)?; - let magic = [0x1A, 0x45, 0xDF, 0xA3]; - b.windows(4).position(|w| w == magic).ok_or_else(|| Error::NotMatroska("no EBML header".into()))? as u64 - }; - let eh = io.header(start, len)?.ok_or_else(|| Error::NotMatroska("bad EBML header".into()))?; - let hdr_end = eh.end(len); - let hdr = io.read_vec(eh.data_start(), hdr_end - eh.data_start())?; - let (mut doc_type, mut dtv, mut dtrv) = ("matroska".to_string(), 1, 1); - for e in Children::new(&hdr) { - match e.id { - DOC_TYPE => doc_type = ebml::string(e.data), - DOC_TYPE_VERSION => dtv = ebml::uint(e.data), - DOC_TYPE_READ_VERSION => dtrv = ebml::uint(e.data), - EBML_MAX_ID_LENGTH if ebml::uint(e.data) > 4 => return Err(Error::Unsupported("EBMLMaxIDLength > 4".into())), - EBML_MAX_SIZE_LENGTH if ebml::uint(e.data) > 8 => return Err(Error::Unsupported("EBMLMaxSizeLength > 8".into())), - _ => {} - } - } - if doc_type != "matroska" && doc_type != "webm" { - return Err(Error::NotMatroska(format!("DocType {doc_type:?}"))); - } - // Segment - let mut pos = hdr_end; - let seg = loop { - let h = io.header(pos, len)?.ok_or_else(|| Error::Invalid("no Segment".into()))?; - if h.id == SEGMENT { - break h; - } - if h.size == Size::Unknown { - return Err(Error::Invalid("unknown-size element before Segment".into())); - } - pos = h.end(len); - }; - let mut file = MkvFile { - doc_type, - doc_type_version: dtv, - doc_type_read_version: dtrv, - segment_data_start: seg.data_start(), - segment_end: seg.end(len), - live: seg.size == Size::Unknown, - info: SegmentInfo::default(), - tracks: Vec::new(), - seek_head: Vec::new(), - cues: Vec::new(), - clusters: Vec::new(), - chapters: Vec::new(), - tags: Vec::new(), - attachments: Vec::new(), - first_cluster: None, - indexed: false, - crc_checked: 0, - crc_errors: Vec::new(), - warnings: Vec::new(), - }; - if seg.overruns(len) { - file.warnings.push("Segment extends past end of file (truncated)".into()); - } - let mut w = Walker { opts, parsed: HashSet::new(), blocks_seen: false }; - w.walk(&mut io, &mut file)?; - finish_tracks(&mut file); - Ok(file) -} - -struct Walker<'o> { - opts: &'o OpenOptions, - parsed: HashSet, - blocks_seen: bool, -} - -impl Walker<'_> { - fn walk(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile) -> Result<()> { - let seg_end = file.segment_end; - let mut pos = file.segment_data_start; - while pos < seg_end { - let Some(h) = io.header(pos, seg_end)? else { - match io.resync(pos + 1, seg_end)? { - Some(p) => { - file.warnings.push(format!("undecodable element at {pos}; resynced at {p}")); - pos = p; - continue; - } - None => break, - } - }; - match h.id { - CLUSTER => { - if file.first_cluster.is_none() { - file.first_cluster = Some(h.pos); - // make sure Info/Tracks are known before interpreting blocks - self.follow_seek_head(io, file, &[INFO, TRACKS])?; - if !self.opts.index { - break; - } - } - let (ci, blocks) = scan_cluster(io, &h, seg_end, &file.tracks, &mut file.warnings)?; - if self.opts.verify_crc && h.size != Size::Unknown { - let data = io.read_vec(h.data_start(), ci.end - h.data_start())?; - self.check_crc(file, h.pos, &data); - } - add_blocks(file, file.clusters.len() as u32, &blocks); - self.blocks_seen = true; - file.clusters.push(ci); - pos = ci.end.max(h.data_start()); - } - SEEK_HEAD | INFO | TRACKS | CUES | CHAPTERS | TAGS | ATTACHMENTS => { - pos = self.level1(io, file, &h, seg_end)?; - } - EBML | SEGMENT => break, // chained segment: only the first one is read - _ => { - if h.size == Size::Unknown { - pos = io.unknown_end(h.data_start(), seg_end)?; - } else if h.overruns(seg_end) && h.id != VOID { - match io.resync(pos + 1, seg_end)? { - Some(p) => { - file.warnings.push(format!("oversized element 0x{:X} at {pos}; resynced at {p}", h.id)); - pos = p; - } - None => break, - } - } else { - pos = h.end(seg_end); - } - } - } - } - self.follow_seek_head(io, file, &[INFO, TRACKS, CUES, CHAPTERS, TAGS, ATTACHMENTS, SEEK_HEAD])?; - // follow a second-level SeekHead reached through the first one - self.follow_seek_head(io, file, &[CUES, CHAPTERS, TAGS, ATTACHMENTS])?; - file.cues.sort_by_key(|c| c.time); - file.indexed = self.opts.index; - Ok(()) - } - - fn follow_seek_head(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, ids: &[u32]) -> Result<()> { - let entries = file.seek_head.clone(); - for e in entries { - if !ids.contains(&e.id) { - continue; - } - let Some(abs) = file.segment_data_start.checked_add(e.position) else { continue }; - if self.parsed.contains(&abs) || abs >= file.segment_end { - continue; - } - match io.header(abs, file.segment_end)? { - Some(h) if h.id == e.id => { - self.level1(io, file, &h, file.segment_end)?; - } - _ => file.warnings.push(format!("SeekHead entry 0x{:X} -> {abs} does not point at that element", e.id)), - } - } - Ok(()) - } - - fn check_crc(&self, file: &mut MkvFile, pos: u64, data: &[u8]) { - if let Some(ok) = ebml::verify_crc(data) { - file.crc_checked += 1; - if !ok { - file.crc_errors.push(pos); - file.warnings.push(format!("CRC-32 mismatch in element at {pos}")); - } - } - } - - /// Parse a level-1 metadata element; returns its end. - fn level1(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, h: &Hdr, limit: u64) -> Result { - let end = if h.size == Size::Unknown { io.unknown_end(h.data_start(), limit)? } else { h.end(limit) }; - if h.overruns(limit) { - file.warnings.push(format!("element 0x{:X} at {} truncated", h.id, h.pos)); - } - if !self.parsed.insert(h.pos) { - return Ok(end); - } - if h.id == ATTACHMENTS { - self.attachments(io, file, h.data_start(), end)?; - return Ok(end); - } - let n = end - h.data_start(); - if n > MAX_META { - file.warnings.push(format!("element 0x{:X} at {} too large ({n} bytes); skipped", h.id, h.pos)); - return Ok(end); - } - let data = io.read_vec(h.data_start(), n)?; - if self.opts.verify_crc { - self.check_crc(file, h.pos, &data); - } - match h.id { - SEEK_HEAD => file.seek_head.extend(parse_seekhead(&data)), - INFO => file.info = parse_info(&data), - TRACKS if file.tracks.is_empty() => { - file.tracks = parse_tracks(&data, file.info.timestamp_scale); - } - CUES => parse_cues(&data, &mut file.cues), - CHAPTERS => parse_chapters(&data, &mut file.chapters), - TAGS => parse_tags(&data, &mut file.tags), - _ => {} - } - Ok(end) - } - - fn attachments(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, start: u64, end: u64) -> Result<()> { - let mut pos = start; - while pos < end { - let Some(h) = io.header(pos, end)? else { break }; - let he = h.end(end); - if h.id == ATTACHED_FILE { - let mut a = Attachment::default(); - let mut p = h.data_start(); - while p < he { - let Some(c) = io.header(p, he)? else { break }; - let ce = c.end(he); - let n = ce - c.data_start(); - if c.id == FILE_DATA { - a.data_offset = c.data_start(); - a.data_size = n; - } else if n <= 4096 { - let d = io.peek(c.data_start(), n as usize)?; - match c.id { - FILE_NAME => a.name = ebml::string(d), - FILE_MEDIA_TYPE => a.media_type = ebml::string(d), - FILE_DESCRIPTION => a.description = Some(ebml::string(d)), - FILE_UID => a.uid = ebml::uint(d), - _ => {} - } - } - p = ce; - } - file.attachments.push(a); - } - pos = he; - } - Ok(()) - } -} - -fn add_blocks(file: &mut MkvFile, cluster: u32, blocks: &[BlockRef]) { - let scale = file.info.timestamp_scale; - let mut frames: Vec = Vec::new(); - for b in blocks { - frames.clear(); - let t = &file.tracks[b.track]; - expand(b, t, scale, 0, &mut frames); - let t = &mut file.tracks[b.track]; - for f in &frames { - if f.keyframe { - t.sync.push(t.samples.len() as u32); - } - t.samples.push(Sample { - offset: f.offset, - size: f.size, - pts: f.pts, - duration: f.duration, - keyframe: f.keyframe, - cluster, - block_offset: f.block_offset, - lace: f.lace, - addition: f.addition, - }); - } - } -} - -fn finish_tracks(file: &mut MkvFile) { - let scale = file.info.timestamp_scale; - let g = gcd(scale, 1_000_000_000); - for t in &mut file.tracks { - t.timebase = (scale / g, 1_000_000_000 / g); - } -} - -// --------------------------------------------------------------------------------------------- -// MkvFile helpers -// --------------------------------------------------------------------------------------------- - -impl MkvFile { - /// True for WebM (`DocType` = `webm`). - pub fn is_webm(&self) -> bool { - self.doc_type == "webm" - } - - /// Segment duration in ns: `Info/Duration` if present, else the end of the last indexed sample. - pub fn duration_ns(&self) -> Option { - if let Some(d) = self.info.duration { - return Some((d * self.info.timestamp_scale as f64).round() as u64); - } - let end = self.tracks.iter().flat_map(|t| t.samples.iter().map(|s| s.pts + s.duration as i64)).max()?; - Some((end.max(0) as u64).saturating_mul(self.info.timestamp_scale)) - } - - /// Index of the track with this `TrackNumber`. - pub fn track_by_number(&self, number: u64) -> Option { - self.tracks.iter().position(|t| t.number == number) - } - - /// Index of the first track of the given kind (preferring the default flag). - pub fn track_of_kind(&self, kind: TrackKind) -> Option { - let mut it = self.tracks.iter().enumerate().filter(|(_, t)| t.kind == kind); - let first = it.clone().next().map(|x| x.0); - it.find(|(_, t)| t.default).map(|x| x.0).or(first) - } - - /// Read sample `index` of `track` (header stripping undone). - pub fn read_sample(&self, src: &S, track: usize, index: usize) -> Result> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - if !t.frames_readable() { - return Err(Error::Unsupported(format!("content encoding of track {} (compression/encryption)", t.number))); - } - let mut data = t.frame_prefix(s.size as u64); - let at = data.len(); - data.resize(at + s.size as usize, 0); - src.read_at(s.offset, &mut data[at..])?; - Ok(data) - } - - /// The `BlockAdditional` (ID 1) of sample `index` of `track`, `None` when the block has none - /// (see [`Sample::addition`]). Stored as is: header stripping applies to frames only. - pub fn read_sample_addition(&self, src: &S, track: usize, index: usize) -> Result>> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - let Some((offset, size)) = s.addition else { return Ok(None) }; - if offset.checked_add(size as u64).is_none_or(|end| end > src.len()) { - return Err(Error::Invalid("block addition past end of file".into())); - } - let mut data = vec![0; size as usize]; - src.read_at(offset, &mut data)?; - Ok(Some(data)) - } - - /// Keyframe sample of `track` with the greatest pts ≤ `time_ns` (indexed files only). - pub fn keyframe_before(&self, track: usize, time_ns: i64) -> Option { - let t = self.tracks.get(track)?; - t.keyframe_before_pts(t.ns_to_ticks(time_ns)) - } -} - -// --------------------------------------------------------------------------------------------- -// Demuxer (streaming packets + seeking) -// --------------------------------------------------------------------------------------------- - -#[derive(Clone, Copy, Debug)] -struct InCluster { - ts: i64, - end: Option, -} - -/// Packet reader over a [`ByteSource`]: iterates frames in file order and seeks to keyframes. -pub struct Demuxer { - src: S, - file: MkvFile, - opts: OpenOptions, - cache: Cache, - pos: u64, - cluster: Option, - pending: VecDeque, - skip_laces: Option<(u64, u16)>, - done: bool, -} - -impl Demuxer> { - /// Open from any `Read + Seek` stream. - pub fn from_reader(r: R) -> Result { - Self::new(ReadSeekSource::new(r)?) - } -} - -impl<'a> Demuxer<&'a [u8]> { - /// Open from an in-memory byte slice. - pub fn from_slice(data: &'a [u8]) -> Result { - Self::new(data) - } -} - -impl Demuxer { - /// Open with default options (full index). - pub fn new(src: S) -> Result { - Self::with_options(src, OpenOptions::default()) - } - - pub fn with_options(src: S, opts: OpenOptions) -> Result { - let mut cache = Cache::default(); - let file = open_impl(&src, &opts, &mut cache)?; - let pos = file.first_cluster.unwrap_or(file.segment_end); - Ok(Self { src, file, opts, cache, pos, cluster: None, pending: VecDeque::new(), skip_laces: None, done: false }) - } - - pub fn file(&self) -> &MkvFile { - &self.file - } - pub fn tracks(&self) -> &[Track] { - &self.file.tracks - } - pub fn source(&self) -> &S { - &self.src - } - pub fn into_parts(self) -> (S, MkvFile) { - (self.src, self.file) - } - /// Segment duration in ns. - pub fn duration_ns(&self) -> Option { - self.file.duration_ns() - } - - /// Scan all clusters and build sample tables (no-op if already indexed). - pub fn build_index(&mut self) -> Result<()> { - if self.file.indexed { - return Ok(()); - } - let opts = OpenOptions { index: true, verify_crc: self.opts.verify_crc }; - self.file = open_impl(&self.src, &opts, &mut self.cache)?; - Ok(()) - } - - /// Keyframes of `track` (builds the index if needed). - pub fn keyframe_index(&mut self, track: usize) -> Result> { - self.build_index()?; - let t = self.file.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - Ok(t.keyframes() - .iter() - .map(|&k| { - let s = &t.samples[k as usize]; - Keyframe { sample: k as usize, pts: s.pts, pts_ns: t.ticks_to_ns(s.pts), block_offset: s.block_offset } - }) - .collect()) - } - - /// Rewind to the first cluster. - pub fn rewind(&mut self) { - self.pos = self.file.first_cluster.unwrap_or(self.file.segment_end); - self.cluster = None; - self.pending.clear(); - self.skip_laces = None; - self.done = false; - } - - /// Read sample `index` of `track` (requires an index). - pub fn read_sample(&self, track: usize, index: usize) -> Result> { - self.file.read_sample(&self.src, track, index) - } - - /// Position the packet stream at the keyframe of `track` with the greatest pts ≤ `time_ns` - /// (or the track's first keyframe if none precedes it). The next [`Demuxer::next_packet`] - /// call returns packets starting at that keyframe's block (all tracks). - /// - /// Uses the sample index when present, else Cues (refined by scanning the cued cluster), else - /// builds the index first. - pub fn seek(&mut self, track: usize, time_ns: i64) -> Result { - let t = self.file.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let ticks = t.ns_to_ticks(time_ns); - if !self.file.indexed { - let tn = t.number; - let cue = self - .file - .cues - .iter() - .filter(|c| c.time as i64 <= ticks) - .filter_map(|c| c.positions.iter().find(|p| p.track == tn).map(|p| (c.time, *p))) - .next_back() - .or_else(|| self.file.cues.iter().find_map(|c| c.positions.iter().find(|p| p.track == tn).map(|p| (c.time, *p)))); - if let Some((_, cp)) = cue - && let Some(sp) = self.seek_via_cue(track, ticks, cp.cluster_position)? - { - return Ok(sp); - } - self.build_index()?; - } - let t = &self.file.tracks[track]; - let k = t.keyframe_before_pts(ticks).ok_or_else(|| Error::Invalid(format!("track {} has no keyframes", t.number)))?; - let s = t.samples[k]; - let c = self.file.clusters[s.cluster as usize]; - let sp = SeekPoint { track, pts: s.pts, pts_ns: t.ticks_to_ns(s.pts), sample: Some(k), cluster_offset: c.offset, block_offset: s.block_offset }; - self.position_at(c, s.block_offset, s.lace); - Ok(sp) - } - - fn position_at(&mut self, c: ClusterInfo, block_offset: u64, lace: u16) { - self.pending.clear(); - self.done = false; - self.pos = block_offset; - let known = c.end; - self.cluster = Some(InCluster { ts: c.timestamp as i64, end: Some(known) }); - self.skip_laces = (lace > 0).then_some((block_offset, lace)); - } - - fn seek_via_cue(&mut self, track: usize, ticks: i64, cluster_position: u64) -> Result> { - let abs = self.file.segment_data_start + cluster_position; - let mut io = Io::new(&self.src, &mut self.cache); - let Some(h) = io.header(abs, self.file.segment_end)? else { return Ok(None) }; - if h.id != CLUSTER { - self.file.warnings.push(format!("cue points at {abs}, which is not a Cluster")); - return Ok(None); - } - let mut warnings = Vec::new(); - let (ci, blocks) = scan_cluster(&mut io, &h, self.file.segment_end, &self.file.tracks, &mut warnings)?; - let t = &self.file.tracks[track]; - let mut frames = Vec::new(); - for b in blocks.iter().filter(|b| b.track == track && b.keyframe) { - expand(b, t, self.file.info.timestamp_scale, 0, &mut frames); - } - let best = frames.iter().filter(|f| f.pts <= ticks).max_by_key(|f| f.pts).or_else(|| frames.iter().min_by_key(|f| f.pts)).copied(); - let Some(f) = best else { return Ok(None) }; - let sp = SeekPoint { track, pts: f.pts, pts_ns: f.pts_ns, sample: None, cluster_offset: ci.offset, block_offset: f.block_offset }; - self.position_at(ci, f.block_offset, f.lace); - Ok(Some(sp)) - } - - /// Next frame in file order, or `None` at the end of the Segment. - pub fn next_packet(&mut self) -> Result> { - loop { - if let Some(f) = self.pending.pop_front() { - return self.materialize(f).map(Some); - } - if self.done || !self.fill()? { - self.done = true; - return Ok(None); - } - } - } - - fn materialize(&mut self, f: FrameRef) -> Result { - let t = &self.file.tracks[f.track]; - let mut data = if t.frames_readable() { t.frame_prefix(f.size as u64) } else { Vec::new() }; - let at = data.len(); - data.resize(at + f.size as usize, 0); - let mut io = Io::new(&self.src, &mut self.cache); - if (f.size as usize) <= WIN / 2 { - let b = io.peek(f.offset, f.size as usize)?; - if b.len() != f.size as usize { - return Err(Error::Invalid("frame data past end of file".into())); - } - data[at..].copy_from_slice(b); - } else { - self.src.read_at(f.offset, &mut data[at..])?; - } - Ok(Packet { - track: f.track, - track_number: t.number, - pts: f.pts, - pts_ns: f.pts_ns, - duration: f.duration, - duration_ns: f.duration_ns, - keyframe: f.keyframe, - discardable: f.discardable, - invisible: f.invisible, - discard_padding_ns: f.discard_padding, - offset: f.offset, - lace: f.lace, - data, - }) - } - - /// Advance over elements until at least one frame is pending. Returns false at the end. - fn fill(&mut self) -> Result { - let seg_end = self.file.segment_end; - let scale = self.file.info.timestamp_scale; - let mut io = Io::new(&self.src, &mut self.cache); - let lookup = track_lookup(&self.file.tracks); - loop { - if let Some(c) = self.cluster { - let lim = c.end.unwrap_or(seg_end); - if self.pos >= lim { - self.cluster = None; - continue; - } - let Some(h) = io.header(self.pos, lim)? else { - self.cluster = None; - match io.resync(self.pos + 1, seg_end)? { - Some(p) => { - self.pos = p; - continue; - } - None => return Ok(false), - } - }; - if c.end.is_none() && is_top_level(h.id) { - self.cluster = None; - continue; - } - if h.size == Size::Unknown || h.overruns(lim) { - self.cluster = None; - match io.resync(self.pos + 1, seg_end)? { - Some(p) => { - self.pos = p; - continue; - } - None => return Ok(false), - } - } - let he = h.end(lim); - self.pos = he; - match h.id { - TIMESTAMP => { - let v = ebml::uint(io.peek(h.data_start(), (he - h.data_start()) as usize)?) as i64; - self.cluster = Some(InCluster { ts: v, end: c.end }); - } - SIMPLE_BLOCK | BLOCK_GROUP => { - if let Some(b) = parse_block_elem(&mut io, &h, he, c.ts, &lookup)? { - let skip = match self.skip_laces.take() { - Some((off, n)) if off == h.pos => n, - _ => 0, - }; - expand(&b, &self.file.tracks[b.track], scale, skip, &mut self.pending); - if !self.pending.is_empty() { - return Ok(true); - } - } - } - _ => {} - } - continue; - } - if self.pos >= seg_end { - return Ok(false); - } - let Some(h) = io.header(self.pos, seg_end)? else { - match io.resync(self.pos + 1, seg_end)? { - Some(p) => { - self.pos = p; - continue; - } - None => return Ok(false), - } - }; - match h.id { - CLUSTER => { - self.cluster = Some(InCluster { ts: 0, end: h.size.known().map(|_| h.end(seg_end)) }); - self.pos = h.data_start(); - } - EBML | SEGMENT => return Ok(false), - _ => { - self.pos = if h.size == Size::Unknown { io.unknown_end(h.data_start(), seg_end)? } else { h.end(seg_end) }; - if self.pos <= h.pos { - return Ok(false); - } - } - } - } - } -} - -impl Iterator for Demuxer { - type Item = Result; - fn next(&mut self) -> Option> { - match self.next_packet() { - Ok(Some(p)) => Some(Ok(p)), - Ok(None) => None, - Err(e) => { - self.done = true; - Some(Err(e)) - } - } - } -} diff --git a/crates/matroska/src/ebml.rs b/crates/matroska/src/ebml.rs deleted file mode 100644 index 6c2db76de..000000000 --- a/crates/matroska/src/ebml.rs +++ /dev/null @@ -1,476 +0,0 @@ -//! EBML primitives (RFC 8794): variable-length integers, element headers, typed values, CRC-32, -//! and Matroska block lacing (RFC 9559 §10.3). - -use crate::error::{Result, invalid}; - -/// Length in bytes of a VINT whose first byte is `b` (1..=8), or `None` for a zero first byte. -#[inline] -pub fn vint_len(b: u8) -> Option { - if b == 0 { None } else { Some(b.leading_zeros() as usize + 1) } -} - -/// Element data size: known, or "unknown" (all value bits set). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Size { - Known(u64), - Unknown, -} - -impl Size { - pub fn known(self) -> Option { - match self { - Size::Known(n) => Some(n), - Size::Unknown => None, - } - } -} - -/// Decode an unsigned VINT (marker bit stripped). Returns `(value, length, all_ones)`. -pub fn read_vint(data: &[u8]) -> Option<(u64, usize, bool)> { - let first = *data.first()?; - let len = vint_len(first)?; - if data.len() < len { - return None; - } - let mask = if len == 8 { 0 } else { 0xFFu8 >> len }; - let mut v = (first & mask) as u64; - for &b in &data[1..len] { - v = (v << 8) | b as u64; - } - let all_ones = v == (1u64 << (7 * len)) - 1; - Some((v, len, all_ones)) -} - -/// Decode an element data size. All-ones is the reserved "unknown size". -pub fn read_size(data: &[u8]) -> Option<(Size, usize)> { - let (v, len, ones) = read_vint(data)?; - Some((if ones { Size::Unknown } else { Size::Known(v) }, len)) -} - -/// Decode an element ID (marker bits kept, 1..=4 bytes). Rejects all-ones value bits (reserved). -/// All-zero value bits are accepted, because Matroska's `ChapterDisplay` uses ID `0x80`. -pub fn read_id(data: &[u8]) -> Option<(u32, usize)> { - let first = *data.first()?; - let len = vint_len(first)?; - if len > 4 || data.len() < len { - return None; - } - let (v, _, ones) = read_vint(&data[..len])?; - if ones || (v == 0 && len > 1) { - return None; - } - let mut id = 0u32; - for &b in &data[..len] { - id = (id << 8) | b as u32; - } - Some((id, len)) -} - -/// Decode a signed VINT as used by EBML lacing (value − (2^(7·len−1) − 1)). -pub fn read_svint(data: &[u8]) -> Option<(i64, usize)> { - let (v, len, _) = read_vint(data)?; - let bias = (1i64 << (7 * len - 1)) - 1; - Some((v as i64 - bias, len)) -} - -/// Encode an element ID (already carrying its marker bits). -pub fn write_id(out: &mut Vec, id: u32) { - let n = if id >= 0x100_0000 { - 4 - } else if id >= 0x1_0000 { - 3 - } else if id >= 0x100 { - 2 - } else { - 1 - }; - out.extend_from_slice(&id.to_be_bytes()[4 - n..]); -} - -/// Encode a data size as a VINT using the shortest length (or `min_len` if larger). -pub fn write_size(out: &mut Vec, size: u64, min_len: usize) { - let mut len = 1; - while len < 8 && size >= (1u64 << (7 * len)) - 1 { - len += 1; - } - let len = len.max(min_len).min(8); - let v = size | (1u64 << (7 * len)); - out.extend_from_slice(&v.to_be_bytes()[8 - len..]); -} - -/// Encode the "unknown size" marker of the given length. -pub fn write_unknown_size(out: &mut Vec, len: usize) { - out.push(0xFFu8 >> (len - 1)); - out.extend(std::iter::repeat_n(0xFF, len - 1)); -} - -/// Big-endian unsigned integer of 0..=8 bytes. -pub fn uint(data: &[u8]) -> u64 { - data.iter().take(8).fold(0u64, |a, &b| (a << 8) | b as u64) -} - -/// Big-endian two's-complement signed integer of 0..=8 bytes. -pub fn int(data: &[u8]) -> i64 { - let n = data.len().min(8); - if n == 0 { - return 0; - } - let v = uint(&data[..n]); - let shift = 64 - 8 * n as u32; - ((v << shift) as i64) >> shift -} - -/// IEEE float of 0, 4 or 8 bytes. -pub fn float(data: &[u8]) -> f64 { - match data.len() { - 4 => f32::from_be_bytes(data.try_into().unwrap_or([0; 4])) as f64, - 8 => f64::from_be_bytes(data.try_into().unwrap_or([0; 8])), - _ => 0.0, - } -} - -/// String / UTF-8 value: bytes up to the first NUL, lossily decoded. -pub fn string(data: &[u8]) -> String { - let end = data.iter().position(|&b| b == 0).unwrap_or(data.len()); - String::from_utf8_lossy(&data[..end]).into_owned() -} - -/// One element inside an in-memory master element. -#[derive(Clone, Copy, Debug)] -pub struct Elem<'a> { - pub id: u32, - /// Offset of the element header, relative to the start of the parent's data. - pub offset: usize, - pub header_len: usize, - pub data: &'a [u8], - /// The size field was "unknown" (data runs to the end of the parent). - pub unknown_size: bool, - /// The declared size overran the parent and was truncated. - pub truncated: bool, -} - -/// Iterator over the children of an in-memory master element. -/// -/// Damaged data ends the iteration (see [`Children::damaged`]); oversize elements are truncated to -/// the parent's end. -pub struct Children<'a> { - data: &'a [u8], - pos: usize, - damaged: bool, -} - -impl<'a> Children<'a> { - pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0, damaged: false } - } - /// True if iteration stopped on an undecodable header. - pub fn damaged(&self) -> bool { - self.damaged - } -} - -impl<'a> Iterator for Children<'a> { - type Item = Elem<'a>; - fn next(&mut self) -> Option> { - let rest = self.data.get(self.pos..)?; - if rest.is_empty() { - return None; - } - let Some((id, il)) = read_id(rest) else { - self.damaged = true; - self.pos = self.data.len(); - return None; - }; - let Some((size, sl)) = read_size(&rest[il..]) else { - self.damaged = true; - self.pos = self.data.len(); - return None; - }; - let hl = il + sl; - let avail = (rest.len() - hl) as u64; - let (len, unknown, truncated) = match size { - Size::Known(n) if n <= avail => (n as usize, false, false), - Size::Known(_) => (avail as usize, false, true), - Size::Unknown => (avail as usize, true, false), - }; - let e = Elem { id, offset: self.pos, header_len: hl, data: &rest[hl..hl + len], unknown_size: unknown, truncated }; - self.pos += hl + len; - Some(e) - } -} - -/// CRC-32 (ISO 3309 / IEEE 802.3, reflected polynomial 0xEDB88320), as used by EBML CRC-32 elements. -pub fn crc32(data: &[u8]) -> u32 { - crc32_update(0xFFFF_FFFF, data) ^ 0xFFFF_FFFF -} - -/// Incremental form: start with `0xFFFF_FFFF`, finish with `^ 0xFFFF_FFFF`. -pub fn crc32_update(mut crc: u32, data: &[u8]) -> u32 { - static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new(); - let t = TABLE.get_or_init(|| { - let mut t = [0u32; 256]; - for (i, e) in t.iter_mut().enumerate() { - let mut c = i as u32; - for _ in 0..8 { - c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; - } - *e = c; - } - t - }); - for &b in data { - crc = t[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8); - } - crc -} - -/// Check a master element's leading CRC-32 child, if present. `Some(true/false)` if there is one. -pub fn verify_crc(master_data: &[u8]) -> Option { - let mut it = Children::new(master_data); - let first = it.next()?; - if first.id != crate::ids::CRC32 || first.data.len() != 4 { - return None; - } - let stored = u32::from_le_bytes(first.data.try_into().ok()?); - let rest = &master_data[first.offset + first.header_len + 4..]; - Some(crc32(rest) == stored) -} - -/// Block lacing mode (flags bits 1–2). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Lacing { - None, - Xiph, - FixedSize, - Ebml, -} - -impl Lacing { - pub fn from_flags(flags: u8) -> Lacing { - match (flags >> 1) & 3 { - 0 => Lacing::None, - 1 => Lacing::Xiph, - 2 => Lacing::FixedSize, - _ => Lacing::Ebml, - } - } -} - -/// Split laced block payload. `data` starts right after the block's flags byte and runs to the end -/// of the block. Returns `(offset, len)` of each frame relative to `data`. -pub fn parse_lacing(lacing: Lacing, data: &[u8]) -> Result> { - if lacing == Lacing::None { - return Ok(vec![(0, data.len())]); - } - let Some(&n1) = data.first() else { return invalid("laced block without frame count") }; - let count = n1 as usize + 1; - let mut pos = 1usize; - let mut sizes = Vec::with_capacity(count); - match lacing { - Lacing::None => return Ok(vec![(0, data.len())]), - Lacing::Xiph => { - for _ in 0..count - 1 { - let mut s = 0usize; - loop { - let Some(&b) = data.get(pos) else { return invalid("truncated Xiph lace header") }; - pos += 1; - s += b as usize; - if b != 255 { - break; - } - } - sizes.push(s); - } - } - Lacing::Ebml => { - if count > 1 { - let Some((first, l, _)) = read_vint(&data[pos..]) else { return invalid("bad EBML lace size") }; - pos += l; - let mut prev = first as i64; - sizes.push(first as usize); - for _ in 1..count - 1 { - let Some((d, l)) = read_svint(&data[pos..]) else { return invalid("bad EBML lace delta") }; - pos += l; - prev += d; - if prev < 0 { - return invalid("negative EBML lace size"); - } - sizes.push(prev as usize); - } - } - } - Lacing::FixedSize => { - let rest = data.len() - pos; - if !rest.is_multiple_of(count) { - return invalid("fixed-size lacing does not divide block"); - } - sizes.extend(std::iter::repeat_n(rest / count, count - 1)); - } - } - let used: usize = sizes.iter().try_fold(0usize, |a, &s| a.checked_add(s)).unwrap_or(usize::MAX); - if pos > data.len() || used > data.len() - pos { - return invalid("lace sizes exceed block"); - } - sizes.push(data.len() - pos - used); - let mut out = Vec::with_capacity(count); - for s in sizes { - out.push((pos, s)); - pos += s; - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn vint_roundtrip() { - for &v in &[0u64, 1, 126, 127, 128, 16382, 16383, 16384, 1 << 20, (1 << 56) - 2] { - let mut b = Vec::new(); - write_size(&mut b, v, 1); - let (s, l) = read_size(&b).unwrap(); - assert_eq!(s, Size::Known(v), "value {v}"); - assert_eq!(l, b.len()); - } - // 127 needs 2 bytes because 0xFF is "unknown" - let mut b = Vec::new(); - write_size(&mut b, 127, 1); - assert_eq!(b, [0x40, 0x7F]); - // padded length - let mut b = Vec::new(); - write_size(&mut b, 5, 8); - assert_eq!(b, [1, 0, 0, 0, 0, 0, 0, 5]); - assert_eq!(read_size(&b).unwrap(), (Size::Known(5), 8)); - } - - #[test] - fn vint_unknown_and_invalid() { - assert_eq!(read_size(&[0xFF]).unwrap(), (Size::Unknown, 1)); - assert_eq!(read_size(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]).unwrap(), (Size::Unknown, 8)); - for l in 1..=8 { - let mut b = Vec::new(); - write_unknown_size(&mut b, l); - assert_eq!(read_size(&b).unwrap(), (Size::Unknown, l)); - } - assert!(read_size(&[0x00, 1, 2]).is_none()); - assert!(read_size(&[0x40]).is_none()); // truncated - assert_eq!(read_vint(&[0x81]).unwrap(), (1, 1, false)); - assert_eq!(read_vint(&[0x10, 0x00, 0x00, 0x01]).unwrap(), (1, 4, false)); - } - - #[test] - fn ids() { - assert_eq!(read_id(&[0x1A, 0x45, 0xDF, 0xA3, 0x99]).unwrap(), (crate::ids::EBML, 4)); - assert_eq!(read_id(&[0xA3]).unwrap(), (0xA3, 1)); - assert_eq!(read_id(&[0x42, 0x86]).unwrap(), (0x4286, 2)); - assert!(read_id(&[0xFF]).is_none()); // reserved - assert_eq!(read_id(&[0x80]).unwrap(), (0x80, 1)); // ChapterDisplay - assert!(read_id(&[0x40, 0x00]).is_none()); // zero value - assert!(read_id(&[0x08, 0, 0, 0, 1]).is_none()); // 5-byte ID - for id in [0xA3u32, 0x4286, 0x2AD7B1, 0x1F43B675] { - let mut b = Vec::new(); - write_id(&mut b, id); - assert_eq!(read_id(&b).unwrap(), (id, b.len())); - } - } - - #[test] - fn signed_vint() { - assert_eq!(read_svint(&[0xBF]).unwrap(), (0, 1)); // 63 - 63 - assert_eq!(read_svint(&[0x80]).unwrap(), (-63, 1)); - assert_eq!(read_svint(&[0xC0]).unwrap(), (1, 1)); - assert_eq!(read_svint(&[0x5F, 0xFF]).unwrap(), (0, 2)); - assert_eq!(read_svint(&[0x60, 0x00]).unwrap(), (1, 2)); - } - - #[test] - fn values() { - assert_eq!(uint(&[]), 0); - assert_eq!(uint(&[1, 0]), 256); - assert_eq!(int(&[0xFF]), -1); - assert_eq!(int(&[0xFF, 0xFE]), -2); - assert_eq!(int(&[0x7F]), 127); - assert_eq!(float(&1.5f32.to_be_bytes()), 1.5); - assert_eq!(float(&2.25f64.to_be_bytes()), 2.25); - assert_eq!(string(b"abc\0\0"), "abc"); - } - - #[test] - fn crc() { - assert_eq!(crc32(b"123456789"), 0xCBF4_3926); - let payload = [0x42u8, 0x86, 0x81, 0x01]; - let mut m = vec![0xBF, 0x84]; - m.extend_from_slice(&crc32(&payload).to_le_bytes()); - m.extend_from_slice(&payload); - assert_eq!(verify_crc(&m), Some(true)); - m[7] ^= 1; - assert_eq!(verify_crc(&m), Some(false)); - assert_eq!(verify_crc(&payload), None); - } - - #[test] - fn children_iter() { - // two elements, then one whose size overruns - let d = [0x42, 0x86, 0x81, 0x01, 0xEC, 0x82, 0, 0, 0xA3, 0x85, 1, 2]; - let v: Vec<_> = Children::new(&d).collect(); - assert_eq!(v.len(), 3); - assert_eq!(v[0].id, 0x4286); - assert_eq!(v[0].data, &[1]); - assert_eq!(v[1].id, 0xEC); - assert_eq!(v[2].data, &[1, 2]); - assert!(v[2].truncated); - let mut it = Children::new(&[0x00, 0x01]); - assert!(it.next().is_none()); - assert!(it.damaged()); - } - - fn frames<'a>(d: &'a [u8], f: &[(usize, usize)]) -> Vec<&'a [u8]> { - f.iter().map(|&(o, l)| &d[o..o + l]).collect() - } - - #[test] - fn lacing_xiph() { - // 3 frames: 300, 2, rest(3) - let mut d = vec![2u8, 255, 45, 2]; - d.extend(std::iter::repeat_n(7u8, 300)); - d.extend_from_slice(&[1, 2]); - d.extend_from_slice(&[3, 4, 5]); - let f = parse_lacing(Lacing::Xiph, &d).unwrap(); - assert_eq!(f.iter().map(|x| x.1).collect::>(), [300, 2, 3]); - assert_eq!(frames(&d, &f)[2], &[3, 4, 5]); - // exact multiple of 255 needs a terminating 0 - let mut d = vec![1u8, 255, 0]; - d.extend(std::iter::repeat_n(1u8, 255)); - d.push(9); - let f = parse_lacing(Lacing::Xiph, &d).unwrap(); - assert_eq!(f.iter().map(|x| x.1).collect::>(), [255, 1]); - assert!(parse_lacing(Lacing::Xiph, &[1, 200, 1]).is_err()); - } - - #[test] - fn lacing_ebml() { - // 4 frames: 5, 3 (delta -2), 4 (delta +1), rest 2 - let mut d = vec![3u8, 0x85, 0xBF - 2, 0xBF + 1]; - d.extend_from_slice(&[1; 5]); - d.extend_from_slice(&[2; 3]); - d.extend_from_slice(&[3; 4]); - d.extend_from_slice(&[4; 2]); - let f = parse_lacing(Lacing::Ebml, &d).unwrap(); - assert_eq!(f.iter().map(|x| x.1).collect::>(), [5, 3, 4, 2]); - assert_eq!(frames(&d, &f), [&[1u8; 5][..], &[2; 3], &[3; 4], &[4; 2]]); - assert!(parse_lacing(Lacing::Ebml, &[1, 0x90]).is_err()); - } - - #[test] - fn lacing_fixed_and_none() { - let d = [2u8, 1, 1, 2, 2, 3, 3]; - let f = parse_lacing(Lacing::FixedSize, &d).unwrap(); - assert_eq!(frames(&d, &f), [&[1u8, 1][..], &[2, 2], &[3, 3]]); - assert!(parse_lacing(Lacing::FixedSize, &[2, 1, 1]).is_err()); - assert_eq!(parse_lacing(Lacing::None, &[9, 9]).unwrap(), [(0, 2)]); - assert_eq!(Lacing::from_flags(0x80 | 0x06), Lacing::Ebml); - assert_eq!(Lacing::from_flags(0x02), Lacing::Xiph); - assert_eq!(Lacing::from_flags(0x04), Lacing::FixedSize); - } -} diff --git a/crates/matroska/src/error.rs b/crates/matroska/src/error.rs deleted file mode 100644 index 718c5c505..000000000 --- a/crates/matroska/src/error.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::fmt; -use std::io; - -/// Errors produced by the demuxer. -#[derive(Debug)] -pub enum Error { - /// Underlying I/O failure. - Io(io::Error), - /// Structurally invalid data that could not be recovered from. - Invalid(String), - /// Valid but unsupported feature (e.g. zlib-compressed or encrypted tracks when reading data). - Unsupported(String), - /// Not an EBML file, or an EBML file whose DocType is not `matroska`/`webm`. - NotMatroska(String), - /// A track index was out of range. - NoSuchTrack(usize), - /// A sample index was out of range. - NoSuchSample(usize), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Io(e) => write!(f, "I/O error: {e}"), - Error::Invalid(s) => write!(f, "invalid data: {s}"), - Error::Unsupported(s) => write!(f, "unsupported: {s}"), - Error::NotMatroska(s) => write!(f, "not a Matroska/WebM file: {s}"), - Error::NoSuchTrack(i) => write!(f, "track index {i} out of range"), - Error::NoSuchSample(i) => write!(f, "sample index {i} out of range"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Error::Io(e) => Some(e), - _ => None, - } - } -} - -impl From for Error { - fn from(e: io::Error) -> Self { - Error::Io(e) - } -} - -pub type Result = std::result::Result; - -pub(crate) fn invalid(msg: impl Into) -> Result { - Err(Error::Invalid(msg.into())) -} diff --git a/crates/matroska/src/ids.rs b/crates/matroska/src/ids.rs deleted file mode 100644 index 3b7e025c4..000000000 --- a/crates/matroska/src/ids.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! EBML / Matroska element IDs (RFC 8794, RFC 9559). IDs keep their VINT marker bits. -#![allow(dead_code)] - -// EBML header + global elements -pub const EBML: u32 = 0x1A45_DFA3; -pub const EBML_VERSION: u32 = 0x4286; -pub const EBML_READ_VERSION: u32 = 0x42F7; -pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2; -pub const EBML_MAX_SIZE_LENGTH: u32 = 0x42F3; -pub const DOC_TYPE: u32 = 0x4282; -pub const DOC_TYPE_VERSION: u32 = 0x4287; -pub const DOC_TYPE_READ_VERSION: u32 = 0x4285; -pub const CRC32: u32 = 0xBF; -pub const VOID: u32 = 0xEC; - -// Segment and level-1 elements -pub const SEGMENT: u32 = 0x1853_8067; -pub const SEEK_HEAD: u32 = 0x114D_9B74; -pub const INFO: u32 = 0x1549_A966; -pub const TRACKS: u32 = 0x1654_AE6B; -pub const CLUSTER: u32 = 0x1F43_B675; -pub const CUES: u32 = 0x1C53_BB6B; -pub const ATTACHMENTS: u32 = 0x1941_A469; -pub const CHAPTERS: u32 = 0x1043_A770; -pub const TAGS: u32 = 0x1254_C367; - -/// True for IDs that can only appear directly inside a Segment (or are the EBML/Segment headers): -/// used to find the end of unknown-size elements. -pub fn is_top_level(id: u32) -> bool { - matches!(id, EBML | SEGMENT | SEEK_HEAD | INFO | TRACKS | CLUSTER | CUES | ATTACHMENTS | CHAPTERS | TAGS) -} - -// SeekHead -pub const SEEK: u32 = 0x4DBB; -pub const SEEK_ID: u32 = 0x53AB; -pub const SEEK_POSITION: u32 = 0x53AC; - -// Info -pub const SEGMENT_UUID: u32 = 0x73A4; -pub const SEGMENT_FILENAME: u32 = 0x7384; -pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1; -pub const DURATION: u32 = 0x4489; -pub const DATE_UTC: u32 = 0x4461; -pub const TITLE: u32 = 0x7BA9; -pub const MUXING_APP: u32 = 0x4D80; -pub const WRITING_APP: u32 = 0x5741; - -// Cluster -pub const TIMESTAMP: u32 = 0xE7; -pub const POSITION: u32 = 0xA7; -pub const PREV_SIZE: u32 = 0xAB; -pub const SIMPLE_BLOCK: u32 = 0xA3; -pub const BLOCK_GROUP: u32 = 0xA0; -pub const ENCRYPTED_BLOCK: u32 = 0xAF; -pub const SILENT_TRACKS: u32 = 0x5854; -pub const BLOCK: u32 = 0xA1; -pub const BLOCK_ADDITIONS: u32 = 0x75A1; -pub const BLOCK_MORE: u32 = 0xA6; -pub const BLOCK_ADD_ID: u32 = 0xEE; -pub const BLOCK_ADDITIONAL: u32 = 0xA5; -pub const BLOCK_DURATION: u32 = 0x9B; -pub const REFERENCE_PRIORITY: u32 = 0xFA; -pub const REFERENCE_BLOCK: u32 = 0xFB; -pub const CODEC_STATE: u32 = 0xA4; -pub const DISCARD_PADDING: u32 = 0x75A2; - -pub fn is_cluster_child(id: u32) -> bool { - matches!(id, TIMESTAMP | POSITION | PREV_SIZE | SIMPLE_BLOCK | BLOCK_GROUP | ENCRYPTED_BLOCK | SILENT_TRACKS | CRC32 | VOID) -} - -// Tracks -pub const TRACK_ENTRY: u32 = 0xAE; -pub const TRACK_NUMBER: u32 = 0xD7; -pub const TRACK_UID: u32 = 0x73C5; -pub const TRACK_TYPE: u32 = 0x83; -pub const FLAG_ENABLED: u32 = 0xB9; -pub const FLAG_DEFAULT: u32 = 0x88; -pub const FLAG_FORCED: u32 = 0x55AA; -pub const FLAG_HEARING_IMPAIRED: u32 = 0x55AB; -pub const FLAG_VISUAL_IMPAIRED: u32 = 0x55AC; -pub const FLAG_ORIGINAL: u32 = 0x55AE; -pub const FLAG_COMMENTARY: u32 = 0x55AF; -pub const FLAG_LACING: u32 = 0x9C; -pub const DEFAULT_DURATION: u32 = 0x23_E383; -pub const TRACK_TIMESTAMP_SCALE: u32 = 0x23_314F; -pub const NAME: u32 = 0x536E; -pub const LANGUAGE: u32 = 0x22_B59C; -pub const LANGUAGE_BCP47: u32 = 0x22_B59D; -pub const CODEC_ID: u32 = 0x86; -pub const CODEC_PRIVATE: u32 = 0x63A2; -pub const CODEC_NAME: u32 = 0x25_8688; -pub const CODEC_DELAY: u32 = 0x56AA; -pub const SEEK_PRE_ROLL: u32 = 0x56BB; - -// Video -pub const VIDEO: u32 = 0xE0; -pub const FLAG_INTERLACED: u32 = 0x9A; -pub const FIELD_ORDER: u32 = 0x9D; -pub const STEREO_MODE: u32 = 0x53B8; -pub const ALPHA_MODE: u32 = 0x53C0; -pub const PIXEL_WIDTH: u32 = 0xB0; -pub const PIXEL_HEIGHT: u32 = 0xBA; -pub const PIXEL_CROP_BOTTOM: u32 = 0x54AA; -pub const PIXEL_CROP_TOP: u32 = 0x54BB; -pub const PIXEL_CROP_LEFT: u32 = 0x54CC; -pub const PIXEL_CROP_RIGHT: u32 = 0x54DD; -pub const DISPLAY_WIDTH: u32 = 0x54B0; -pub const DISPLAY_HEIGHT: u32 = 0x54BA; -pub const DISPLAY_UNIT: u32 = 0x54B2; -pub const COLOUR_SPACE: u32 = 0x2E_B524; -pub const COLOUR: u32 = 0x55B0; -pub const PROJECTION: u32 = 0x7670; -pub const PROJECTION_TYPE: u32 = 0x7671; -pub const PROJECTION_POSE_YAW: u32 = 0x7673; -pub const PROJECTION_POSE_PITCH: u32 = 0x7674; -pub const PROJECTION_POSE_ROLL: u32 = 0x7675; - -// Colour -pub const MATRIX_COEFFICIENTS: u32 = 0x55B1; -pub const BITS_PER_CHANNEL: u32 = 0x55B2; -pub const CHROMA_SUBSAMPLING_HORZ: u32 = 0x55B3; -pub const CHROMA_SUBSAMPLING_VERT: u32 = 0x55B4; -pub const CB_SUBSAMPLING_HORZ: u32 = 0x55B5; -pub const CB_SUBSAMPLING_VERT: u32 = 0x55B6; -pub const CHROMA_SITING_HORZ: u32 = 0x55B7; -pub const CHROMA_SITING_VERT: u32 = 0x55B8; -pub const RANGE: u32 = 0x55B9; -pub const TRANSFER_CHARACTERISTICS: u32 = 0x55BA; -pub const PRIMARIES: u32 = 0x55BB; -pub const MAX_CLL: u32 = 0x55BC; -pub const MAX_FALL: u32 = 0x55BD; -pub const MASTERING_METADATA: u32 = 0x55D0; -pub const PRIMARY_R_X: u32 = 0x55D1; -pub const PRIMARY_R_Y: u32 = 0x55D2; -pub const PRIMARY_G_X: u32 = 0x55D3; -pub const PRIMARY_G_Y: u32 = 0x55D4; -pub const PRIMARY_B_X: u32 = 0x55D5; -pub const PRIMARY_B_Y: u32 = 0x55D6; -pub const WHITE_POINT_X: u32 = 0x55D7; -pub const WHITE_POINT_Y: u32 = 0x55D8; -pub const LUMINANCE_MAX: u32 = 0x55D9; -pub const LUMINANCE_MIN: u32 = 0x55DA; - -// Audio -pub const AUDIO: u32 = 0xE1; -pub const SAMPLING_FREQUENCY: u32 = 0xB5; -pub const OUTPUT_SAMPLING_FREQUENCY: u32 = 0x78B5; -pub const CHANNELS: u32 = 0x9F; -pub const BIT_DEPTH: u32 = 0x6264; - -// ContentEncodings -pub const CONTENT_ENCODINGS: u32 = 0x6D80; -pub const CONTENT_ENCODING: u32 = 0x6240; -pub const CONTENT_ENCODING_ORDER: u32 = 0x5031; -pub const CONTENT_ENCODING_SCOPE: u32 = 0x5032; -pub const CONTENT_ENCODING_TYPE: u32 = 0x5033; -pub const CONTENT_COMPRESSION: u32 = 0x5034; -pub const CONTENT_COMP_ALGO: u32 = 0x4254; -pub const CONTENT_COMP_SETTINGS: u32 = 0x4255; -pub const CONTENT_ENCRYPTION: u32 = 0x5035; - -// Cues -pub const CUE_POINT: u32 = 0xBB; -pub const CUE_TIME: u32 = 0xB3; -pub const CUE_TRACK_POSITIONS: u32 = 0xB7; -pub const CUE_TRACK: u32 = 0xF7; -pub const CUE_CLUSTER_POSITION: u32 = 0xF1; -pub const CUE_RELATIVE_POSITION: u32 = 0xF0; -pub const CUE_DURATION: u32 = 0xB2; -pub const CUE_BLOCK_NUMBER: u32 = 0x5378; - -// Attachments -pub const ATTACHED_FILE: u32 = 0x61A7; -pub const FILE_DESCRIPTION: u32 = 0x467E; -pub const FILE_NAME: u32 = 0x466E; -pub const FILE_MEDIA_TYPE: u32 = 0x4660; -pub const FILE_DATA: u32 = 0x465C; -pub const FILE_UID: u32 = 0x46AE; - -// Chapters -pub const EDITION_ENTRY: u32 = 0x45B9; -pub const EDITION_UID: u32 = 0x45BC; -pub const EDITION_FLAG_HIDDEN: u32 = 0x45BD; -pub const EDITION_FLAG_DEFAULT: u32 = 0x45DB; -pub const EDITION_FLAG_ORDERED: u32 = 0x45DD; -pub const CHAPTER_ATOM: u32 = 0xB6; -pub const CHAPTER_UID: u32 = 0x73C4; -pub const CHAPTER_STRING_UID: u32 = 0x5654; -pub const CHAPTER_TIME_START: u32 = 0x91; -pub const CHAPTER_TIME_END: u32 = 0x92; -pub const CHAPTER_FLAG_HIDDEN: u32 = 0x98; -pub const CHAPTER_FLAG_ENABLED: u32 = 0x4598; -pub const CHAPTER_DISPLAY: u32 = 0x80; -pub const CHAP_STRING: u32 = 0x85; -pub const CHAP_LANGUAGE: u32 = 0x437C; -pub const CHAP_LANGUAGE_BCP47: u32 = 0x437D; -pub const CHAP_COUNTRY: u32 = 0x437E; - -// Tags -pub const TAG: u32 = 0x7373; -pub const TARGETS: u32 = 0x63C0; -pub const TARGET_TYPE_VALUE: u32 = 0x68CA; -pub const TARGET_TYPE: u32 = 0x63CA; -pub const TAG_TRACK_UID: u32 = 0x63C5; -pub const TAG_EDITION_UID: u32 = 0x63C9; -pub const TAG_CHAPTER_UID: u32 = 0x63C4; -pub const TAG_ATTACHMENT_UID: u32 = 0x63C6; -pub const SIMPLE_TAG: u32 = 0x67C8; -pub const TAG_NAME: u32 = 0x45A3; -pub const TAG_LANGUAGE: u32 = 0x447A; -pub const TAG_LANGUAGE_BCP47: u32 = 0x447B; -pub const TAG_DEFAULT: u32 = 0x4484; -pub const TAG_STRING: u32 = 0x4487; -pub const TAG_BINARY: u32 = 0x4485; diff --git a/crates/matroska/src/lib.rs b/crates/matroska/src/lib.rs index 5901e5e97..94428f619 100644 --- a/crates/matroska/src/lib.rs +++ b/crates/matroska/src/lib.rs @@ -1,38 +1,6 @@ -//! Clean-room Matroska (MKV) and WebM demuxer, written from RFC 8794 (EBML), RFC 9559 (Matroska) -//! and the WebM container guidelines. -//! -//! - [`open`] / [`open_with`] parse the EBML header and the first Segment (Info, Tracks, SeekHead, -//! Cues, Chapters, Tags, Attachments) and, by default, scan every Cluster's block headers to build -//! per-track [`Sample`] tables — the same shape as `filmcraft-isobmff`, so callers can treat both -//! containers alike. Frame data is read on demand with [`MkvFile::read_sample`]. -//! - [`Demuxer`] iterates [`Packet`]s in file order (from any [`ByteSource`], `Read + Seek`, or a -//! byte slice) and seeks to the keyframe preceding a time, via the index, Cues, or a cluster scan. -//! -//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. +//! Matroska (MKV) and WebM demuxer and muxer: a re-export of [`craft_media::matroska`] from +//! craft-libs. Supported elements and limitations are in the craft-media README. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -mod codec; -mod demux; -pub mod ebml; -mod error; -mod ids; -mod meta; -mod mux; -mod source; -#[cfg(test)] -mod synthetic_tests; -mod track; - -pub use codec::Codec; -pub use demux::{Demuxer, Keyframe, MkvFile, OpenOptions, Packet, SeekPoint, open, open_with}; -pub use error::{Error, Result}; -pub use meta::{Attachment, Chapter, ChapterDisplay, ClusterInfo, CuePoint, CuePosition, Edition, SeekEntry, SegmentInfo, SimpleTag, Tag, TagTargets}; -pub use mux::{LacingMode, MkvWriter, MuxOptions, TrackSpec}; -pub use source::{ByteSource, ReadSeekSource}; -pub use track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Sample, Track, TrackKind, VideoInfo}; - -/// Element IDs used by the demuxer (marker bits included), for callers inspecting raw elements. -pub mod element_ids { - pub use crate::ids::*; -} +pub use craft_media::matroska::*; diff --git a/crates/matroska/src/meta.rs b/crates/matroska/src/meta.rs deleted file mode 100644 index 3ae698646..000000000 --- a/crates/matroska/src/meta.rs +++ /dev/null @@ -1,608 +0,0 @@ -//! Segment-level metadata: Info, SeekHead, Cues, Chapters, Tags, Attachments — types and parsers -//! for their in-memory element data. - -use crate::codec::map_codec; -use crate::ebml::{Children, float, int, string, uint}; -use crate::ids::*; -use crate::track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Track, TrackKind, VideoInfo, gcd}; - -// A malformed Matroska file may contain arbitrarily deep ChapterAtom and SimpleTag chains. -const MAX_META_DEPTH: usize = 64; - -/// `Info` element. -#[derive(Clone, Debug, PartialEq)] -pub struct SegmentInfo { - /// Nanoseconds per timestamp tick (default 1 000 000 = 1 ms). - pub timestamp_scale: u64, - /// `Duration` in timestamp ticks (float), if present. - pub duration: Option, - pub title: Option, - pub muxing_app: String, - pub writing_app: String, - /// `DateUTC`: nanoseconds since 2001-01-01T00:00:00 UTC. - pub date_utc: Option, - pub segment_uuid: Option<[u8; 16]>, -} - -impl Default for SegmentInfo { - fn default() -> Self { - Self { - timestamp_scale: 1_000_000, - duration: None, - title: None, - muxing_app: String::new(), - writing_app: String::new(), - date_utc: None, - segment_uuid: None, - } - } -} - -/// One `SeekHead` entry: level-1 element ID and its position relative to the Segment data start. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SeekEntry { - pub id: u32, - pub position: u64, -} - -/// One `CueTrackPositions` of a `CuePoint`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CuePosition { - pub track: u64, - /// Cluster position relative to the Segment data start. - pub cluster_position: u64, - /// Block position relative to the Cluster data start. - pub relative_position: Option, - pub block_number: Option, - pub duration: Option, -} - -/// One `CuePoint`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CuePoint { - /// In timestamp ticks. - pub time: u64, - pub positions: Vec, -} - -/// A cluster found while scanning. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ClusterInfo { - /// Absolute offset of the Cluster element header. - pub offset: u64, - /// Absolute offset of the first child. - pub data_start: u64, - /// Absolute end offset (for unknown-size clusters, where the next top-level element starts). - pub end: u64, - /// Cluster `Timestamp` in ticks. - pub timestamp: u64, -} - -/// One `ChapterDisplay`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ChapterDisplay { - pub string: String, - pub languages: Vec, - pub languages_bcp47: Vec, - pub countries: Vec, -} - -/// `ChapterAtom` (possibly nested). -#[derive(Clone, Debug, PartialEq)] -pub struct Chapter { - pub uid: u64, - pub string_uid: Option, - pub start_ns: u64, - pub end_ns: Option, - pub hidden: bool, - pub enabled: bool, - pub displays: Vec, - pub children: Vec, -} - -impl Chapter { - /// First display string, if any. - pub fn title(&self) -> Option<&str> { - self.displays.first().map(|d| d.string.as_str()) - } -} - -/// `EditionEntry`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Edition { - pub uid: Option, - pub hidden: bool, - pub default: bool, - pub ordered: bool, - pub chapters: Vec, -} - -/// `Targets` of a `Tag`. Empty UID lists mean "the whole segment". -#[derive(Clone, Debug, PartialEq)] -pub struct TagTargets { - pub type_value: u64, - pub type_name: Option, - pub track_uids: Vec, - pub edition_uids: Vec, - pub chapter_uids: Vec, - pub attachment_uids: Vec, -} - -impl Default for TagTargets { - fn default() -> Self { - Self { type_value: 50, type_name: None, track_uids: Vec::new(), edition_uids: Vec::new(), chapter_uids: Vec::new(), attachment_uids: Vec::new() } - } -} - -/// `SimpleTag` (possibly nested). -#[derive(Clone, Debug, PartialEq)] -pub struct SimpleTag { - pub name: String, - pub language: String, - pub default: bool, - pub string: Option, - pub binary: Option>, - pub children: Vec, -} - -/// `Tag`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Tag { - pub targets: TagTargets, - pub simple: Vec, -} - -/// `AttachedFile` (listed only; the payload is located by `data_offset`/`data_size`). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Attachment { - pub uid: u64, - pub name: String, - pub media_type: String, - pub description: Option, - pub data_offset: u64, - pub data_size: u64, -} - -pub(crate) fn parse_info(d: &[u8]) -> SegmentInfo { - let mut info = SegmentInfo::default(); - for e in Children::new(d) { - match e.id { - TIMESTAMP_SCALE => { - let v = uint(e.data); - if v > 0 { - info.timestamp_scale = v; - } - } - DURATION => info.duration = Some(float(e.data)).filter(|v| v.is_finite() && *v >= 0.0), - TITLE => info.title = Some(string(e.data)), - MUXING_APP => info.muxing_app = string(e.data), - WRITING_APP => info.writing_app = string(e.data), - DATE_UTC => info.date_utc = Some(int(e.data)), - SEGMENT_UUID => info.segment_uuid = e.data.try_into().ok(), - _ => {} - } - } - info -} - -pub(crate) fn parse_seekhead(d: &[u8]) -> Vec { - let mut out = Vec::new(); - for s in Children::new(d).filter(|e| e.id == SEEK) { - let (mut id, mut pos) = (None, None); - for e in Children::new(s.data) { - match e.id { - SEEK_ID => id = Some(uint(e.data) as u32), - SEEK_POSITION => pos = Some(uint(e.data)), - _ => {} - } - } - if let (Some(id), Some(position)) = (id, pos) { - out.push(SeekEntry { id, position }); - } - } - out -} - -pub(crate) fn parse_cues(d: &[u8], out: &mut Vec) { - for cp in Children::new(d).filter(|e| e.id == CUE_POINT) { - let mut time = None; - let mut positions = Vec::new(); - for e in Children::new(cp.data) { - match e.id { - CUE_TIME => time = Some(uint(e.data)), - CUE_TRACK_POSITIONS => { - let mut p = CuePosition { track: 0, cluster_position: u64::MAX, relative_position: None, block_number: None, duration: None }; - for c in Children::new(e.data) { - match c.id { - CUE_TRACK => p.track = uint(c.data), - CUE_CLUSTER_POSITION => p.cluster_position = uint(c.data), - CUE_RELATIVE_POSITION => p.relative_position = Some(uint(c.data)), - CUE_BLOCK_NUMBER => p.block_number = Some(uint(c.data)), - CUE_DURATION => p.duration = Some(uint(c.data)), - _ => {} - } - } - if p.track != 0 && p.cluster_position != u64::MAX { - positions.push(p); - } - } - _ => {} - } - } - if let Some(time) = time - && !positions.is_empty() - { - out.push(CuePoint { time, positions }); - } - } -} - -fn parse_chapter(d: &[u8], depth: usize) -> Chapter { - let mut c = Chapter { uid: 0, string_uid: None, start_ns: 0, end_ns: None, hidden: false, enabled: true, displays: Vec::new(), children: Vec::new() }; - for e in Children::new(d) { - match e.id { - CHAPTER_UID => c.uid = uint(e.data), - CHAPTER_STRING_UID => c.string_uid = Some(string(e.data)), - CHAPTER_TIME_START => c.start_ns = uint(e.data), - CHAPTER_TIME_END => c.end_ns = Some(uint(e.data)), - CHAPTER_FLAG_HIDDEN => c.hidden = uint(e.data) != 0, - CHAPTER_FLAG_ENABLED => c.enabled = uint(e.data) != 0, - CHAPTER_DISPLAY => { - let mut disp = ChapterDisplay::default(); - for x in Children::new(e.data) { - match x.id { - CHAP_STRING => disp.string = string(x.data), - CHAP_LANGUAGE => disp.languages.push(string(x.data)), - CHAP_LANGUAGE_BCP47 => disp.languages_bcp47.push(string(x.data)), - CHAP_COUNTRY => disp.countries.push(string(x.data)), - _ => {} - } - } - if disp.languages.is_empty() { - disp.languages.push("eng".into()); - } - c.displays.push(disp); - } - CHAPTER_ATOM if depth < MAX_META_DEPTH => c.children.push(parse_chapter(e.data, depth + 1)), - _ => {} - } - } - c -} - -pub(crate) fn parse_chapters(d: &[u8], out: &mut Vec) { - for ed in Children::new(d).filter(|e| e.id == EDITION_ENTRY) { - let mut edition = Edition::default(); - for e in Children::new(ed.data) { - match e.id { - EDITION_UID => edition.uid = Some(uint(e.data)), - EDITION_FLAG_HIDDEN => edition.hidden = uint(e.data) != 0, - EDITION_FLAG_DEFAULT => edition.default = uint(e.data) != 0, - EDITION_FLAG_ORDERED => edition.ordered = uint(e.data) != 0, - CHAPTER_ATOM => edition.chapters.push(parse_chapter(e.data, 0)), - _ => {} - } - } - out.push(edition); - } -} - -fn parse_simple_tag(d: &[u8], depth: usize) -> SimpleTag { - let mut t = SimpleTag { name: String::new(), language: "und".into(), default: true, string: None, binary: None, children: Vec::new() }; - for e in Children::new(d) { - match e.id { - TAG_NAME => t.name = string(e.data), - TAG_LANGUAGE => t.language = string(e.data), - TAG_DEFAULT => t.default = uint(e.data) != 0, - TAG_STRING => t.string = Some(string(e.data)), - TAG_BINARY => t.binary = Some(e.data.to_vec()), - SIMPLE_TAG if depth < MAX_META_DEPTH => t.children.push(parse_simple_tag(e.data, depth + 1)), - _ => {} - } - } - t -} - -pub(crate) fn parse_tags(d: &[u8], out: &mut Vec) { - for tg in Children::new(d).filter(|e| e.id == TAG) { - let mut tag = Tag::default(); - for e in Children::new(tg.data) { - match e.id { - TARGETS => { - for x in Children::new(e.data) { - match x.id { - TARGET_TYPE_VALUE => tag.targets.type_value = uint(x.data), - TARGET_TYPE => tag.targets.type_name = Some(string(x.data)), - TAG_TRACK_UID => tag.targets.track_uids.push(uint(x.data)), - TAG_EDITION_UID => tag.targets.edition_uids.push(uint(x.data)), - TAG_CHAPTER_UID => tag.targets.chapter_uids.push(uint(x.data)), - TAG_ATTACHMENT_UID => tag.targets.attachment_uids.push(uint(x.data)), - _ => {} - } - } - } - SIMPLE_TAG => tag.simple.push(parse_simple_tag(e.data, 0)), - _ => {} - } - } - out.push(tag); - } -} - -fn parse_colour(d: &[u8]) -> Colour { - let mut c = Colour::default(); - for e in Children::new(d) { - let v = || Some(uint(e.data)); - match e.id { - MATRIX_COEFFICIENTS => c.matrix_coefficients = v(), - BITS_PER_CHANNEL => c.bits_per_channel = v(), - CHROMA_SUBSAMPLING_HORZ => c.chroma_subsampling_horz = v(), - CHROMA_SUBSAMPLING_VERT => c.chroma_subsampling_vert = v(), - CB_SUBSAMPLING_HORZ => c.cb_subsampling_horz = v(), - CB_SUBSAMPLING_VERT => c.cb_subsampling_vert = v(), - CHROMA_SITING_HORZ => c.chroma_siting_horz = v(), - CHROMA_SITING_VERT => c.chroma_siting_vert = v(), - RANGE => c.range = v(), - TRANSFER_CHARACTERISTICS => c.transfer_characteristics = v(), - PRIMARIES => c.primaries = v(), - MAX_CLL => c.max_cll = v(), - MAX_FALL => c.max_fall = v(), - MASTERING_METADATA => { - let mut m = MasteringMetadata::default(); - for x in Children::new(e.data) { - let f = float(x.data); - match x.id { - PRIMARY_R_X => m.primaries[0].0 = f, - PRIMARY_R_Y => m.primaries[0].1 = f, - PRIMARY_G_X => m.primaries[1].0 = f, - PRIMARY_G_Y => m.primaries[1].1 = f, - PRIMARY_B_X => m.primaries[2].0 = f, - PRIMARY_B_Y => m.primaries[2].1 = f, - WHITE_POINT_X => m.white_point.0 = f, - WHITE_POINT_Y => m.white_point.1 = f, - LUMINANCE_MAX => m.luminance_max = f, - LUMINANCE_MIN => m.luminance_min = f, - _ => {} - } - } - c.mastering = Some(m); - } - _ => {} - } - } - c -} - -fn parse_video(d: &[u8]) -> VideoInfo { - let mut v = VideoInfo::default(); - for e in Children::new(d) { - let u = uint(e.data); - match e.id { - PIXEL_WIDTH => v.pixel_width = u as u32, - PIXEL_HEIGHT => v.pixel_height = u as u32, - DISPLAY_WIDTH => v.display_width = Some(u as u32), - DISPLAY_HEIGHT => v.display_height = Some(u as u32), - DISPLAY_UNIT => v.display_unit = u, - PIXEL_CROP_TOP => v.crop.0 = u as u32, - PIXEL_CROP_BOTTOM => v.crop.1 = u as u32, - PIXEL_CROP_LEFT => v.crop.2 = u as u32, - PIXEL_CROP_RIGHT => v.crop.3 = u as u32, - FLAG_INTERLACED => v.flag_interlaced = u, - FIELD_ORDER => v.field_order = Some(u), - STEREO_MODE => v.stereo_mode = Some(u), - ALPHA_MODE => v.alpha_mode = u, - COLOUR_SPACE => v.colour_space = e.data.try_into().ok(), - COLOUR => v.colour = Some(parse_colour(e.data)), - PROJECTION => { - let mut p = Projection::default(); - for x in Children::new(e.data) { - match x.id { - PROJECTION_TYPE => p.projection_type = uint(x.data), - PROJECTION_POSE_YAW => p.yaw = float(x.data), - PROJECTION_POSE_PITCH => p.pitch = float(x.data), - PROJECTION_POSE_ROLL => p.roll = float(x.data), - _ => {} - } - } - v.projection = Some(p); - } - _ => {} - } - } - v -} - -fn parse_audio(d: &[u8]) -> AudioInfo { - let mut a = AudioInfo::default(); - for e in Children::new(d) { - match e.id { - SAMPLING_FREQUENCY => a.sampling_frequency = float(e.data), - OUTPUT_SAMPLING_FREQUENCY => a.output_sampling_frequency = Some(float(e.data)), - CHANNELS => a.channels = uint(e.data), - BIT_DEPTH => a.bit_depth = Some(uint(e.data)), - _ => {} - } - } - a -} - -fn parse_encodings(d: &[u8]) -> Vec { - let mut list: Vec<(u64, ContentEncoding)> = Vec::new(); - for ce in Children::new(d).filter(|e| e.id == CONTENT_ENCODING) { - let (mut order, mut scope, mut ty) = (0u64, 1u64, 0u64); - let mut comp: Option<(u64, Vec)> = None; - for e in Children::new(ce.data) { - match e.id { - CONTENT_ENCODING_ORDER => order = uint(e.data), - CONTENT_ENCODING_SCOPE => scope = uint(e.data), - CONTENT_ENCODING_TYPE => ty = uint(e.data), - CONTENT_COMPRESSION => { - let (mut algo, mut settings) = (0u64, Vec::new()); - for x in Children::new(e.data) { - match x.id { - CONTENT_COMP_ALGO => algo = uint(x.data), - CONTENT_COMP_SETTINGS => settings = x.data.to_vec(), - _ => {} - } - } - comp = Some((algo, settings)); - } - _ => {} - } - } - let enc = if ty == 1 { - ContentEncoding::Encryption { scope } - } else { - let (algo, settings) = comp.unwrap_or((0, Vec::new())); - if algo == 3 { ContentEncoding::HeaderStripping { bytes: settings, scope } } else { ContentEncoding::Compression { algo, settings, scope } } - }; - list.push((order, enc)); - } - // highest order is applied last on muxing → first to undo; keep decode order - list.sort_by_key(|e| std::cmp::Reverse(e.0)); - list.into_iter().map(|e| e.1).collect() -} - -pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec { - let g = gcd(timestamp_scale, 1_000_000_000); - let timebase = (timestamp_scale / g, 1_000_000_000 / g); - let mut out = Vec::new(); - for te in Children::new(d).filter(|e| e.id == TRACK_ENTRY) { - let mut t = Track { - number: 0, - uid: 0, - kind: TrackKind::Other(0), - enabled: true, - default: true, - forced: false, - hearing_impaired: false, - lacing: true, - default_duration_ns: None, - name: String::new(), - language: "eng".into(), - language_bcp47: None, - codec_id: String::new(), - codec_private: Vec::new(), - codec_name: String::new(), - codec: crate::Codec::Other(String::new()), - codec_delay_ns: 0, - seek_pre_roll_ns: 0, - video: None, - audio: None, - encodings: Vec::new(), - timebase, - samples: Vec::new(), - sync: Vec::new(), - }; - for e in Children::new(te.data) { - match e.id { - TRACK_NUMBER => t.number = uint(e.data), - TRACK_UID => t.uid = uint(e.data), - TRACK_TYPE => t.kind = TrackKind::from_type(uint(e.data)), - FLAG_ENABLED => t.enabled = uint(e.data) != 0, - FLAG_DEFAULT => t.default = uint(e.data) != 0, - FLAG_FORCED => t.forced = uint(e.data) != 0, - FLAG_HEARING_IMPAIRED => t.hearing_impaired = uint(e.data) != 0, - FLAG_LACING => t.lacing = uint(e.data) != 0, - DEFAULT_DURATION => t.default_duration_ns = Some(uint(e.data)).filter(|&v| v > 0), - NAME => t.name = string(e.data), - LANGUAGE => t.language = string(e.data), - LANGUAGE_BCP47 => t.language_bcp47 = Some(string(e.data)), - CODEC_ID => t.codec_id = string(e.data), - CODEC_PRIVATE => t.codec_private = e.data.to_vec(), - CODEC_NAME => t.codec_name = string(e.data), - CODEC_DELAY => t.codec_delay_ns = uint(e.data), - SEEK_PRE_ROLL => t.seek_pre_roll_ns = uint(e.data), - VIDEO => t.video = Some(parse_video(e.data)), - AUDIO => t.audio = Some(parse_audio(e.data)), - CONTENT_ENCODINGS => t.encodings = parse_encodings(e.data), - _ => {} - } - } - if t.number == 0 { - continue; - } - if t.kind == TrackKind::Audio && t.audio.is_none() { - t.audio = Some(AudioInfo::default()); - } - // CodecPrivate may itself be header-stripped (scope bit 2 = private data). - // Compose all applicable prefixes once: repeatedly cloning the complete - // private data lets many compact encoding entries multiply allocation. - restore_codec_private(&t.encodings, &mut t.codec_private); - t.codec = map_codec(&t.codec_id, &t.codec_private, t.audio.as_ref()); - out.push(t); - } - out -} - -fn restore_codec_private(encodings: &[ContentEncoding], codec_private: &mut Vec) { - let prefix_len = encodings - .iter() - .filter_map(|enc| match enc { - ContentEncoding::HeaderStripping { bytes, scope } if scope & 2 != 0 && !bytes.is_empty() => Some(bytes.len()), - _ => None, - }) - .fold(0usize, usize::saturating_add); - if prefix_len == 0 { - return; - } - let Some(capacity) = prefix_len.checked_add(codec_private.len()) else { return }; - let mut restored = Vec::with_capacity(capacity); - // Each successive stripping encoding prepends its bytes, so restore in - // reverse declaration order to preserve the prior result. - for enc in encodings.iter().rev() { - if let ContentEncoding::HeaderStripping { bytes, scope } = enc - && scope & 2 != 0 - && !bytes.is_empty() - { - restored.extend_from_slice(bytes); - } - } - restored.extend_from_slice(codec_private); - *codec_private = restored; -} - -#[cfg(test)] -mod metadata_depth_tests { - use super::*; - - #[test] - fn deeply_nested_chapters_stop_at_depth_limit() { - // Unknown-size elements make every pair the sole child of the previous ChapterAtom. - let data = [0xB6, 0xFF].repeat(100_000); - let chapter = parse_chapter(&data, 0); - let mut current = &chapter; - for _ in 0..MAX_META_DEPTH { - assert_eq!(current.children.len(), 1); - current = ¤t.children[0]; - } - assert!(current.children.is_empty()); - } - - #[test] - fn codec_private_header_stripping_is_composed_once_in_reverse_order() { - let encodings = vec![ - ContentEncoding::HeaderStripping { bytes: b"first".to_vec(), scope: 2 }, - ContentEncoding::HeaderStripping { bytes: Vec::new(), scope: 2 }, - ContentEncoding::HeaderStripping { bytes: b"second".to_vec(), scope: 2 }, - ContentEncoding::HeaderStripping { bytes: b"frame".to_vec(), scope: 1 }, - ]; - let mut private = b"private".to_vec(); - - restore_codec_private(&encodings, &mut private); - - assert_eq!(private, b"secondfirstprivate"); - } - - #[test] - fn deeply_nested_simple_tags_stop_at_depth_limit() { - let data = [0x67, 0xC8, 0xFF].repeat(100_000); - let tag = parse_simple_tag(&data, 0); - let mut current = &tag; - for _ in 0..MAX_META_DEPTH { - assert_eq!(current.children.len(), 1); - current = ¤t.children[0]; - } - assert!(current.children.is_empty()); - } -} diff --git a/crates/matroska/src/mux.rs b/crates/matroska/src/mux.rs deleted file mode 100644 index f809b1beb..000000000 --- a/crates/matroska/src/mux.rs +++ /dev/null @@ -1,539 +0,0 @@ -//! Minimal Matroska / WebM muxer: SimpleBlocks (optionally laced), BlockGroups for explicit -//! durations, Cues, SeekHead, Info with Duration. Output must be `Write + Seek` (sizes and the -//! SeekHead are back-patched on [`MkvWriter::finish`]). - -use std::io::{Seek, SeekFrom, Write}; - -use crate::ebml::{write_id, write_size, write_unknown_size}; -use crate::error::{Error, Result}; -use crate::ids::*; -use crate::track::TrackKind; - -/// Lacing used for audio tracks. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LacingMode { - None, - Xiph, - Ebml, - /// Fixed-size lacing when consecutive frames have equal sizes, otherwise EBML lacing. - FixedOrEbml, -} - -/// Track description for [`MkvWriter`]. -#[derive(Clone, Debug)] -pub struct TrackSpec { - pub kind: TrackKind, - pub codec_id: String, - pub codec_private: Vec, - pub default_duration_ns: Option, - pub language: Option, - pub name: Option, - /// Pixel width/height for video tracks. - pub video_size: Option<(u32, u32)>, - /// (sampling frequency, channels, bit depth) for audio tracks. - pub audio: Option<(f64, u64, Option)>, - pub codec_delay_ns: u64, - pub seek_pre_roll_ns: u64, -} - -impl TrackSpec { - pub fn new(kind: TrackKind, codec_id: impl Into) -> Self { - Self { - kind, - codec_id: codec_id.into(), - codec_private: Vec::new(), - default_duration_ns: None, - language: None, - name: None, - video_size: None, - audio: None, - codec_delay_ns: 0, - seek_pre_roll_ns: 0, - } - } -} - -/// Muxer options. -#[derive(Clone, Debug)] -pub struct MuxOptions { - /// `webm` or `matroska`. - pub doc_type: String, - /// Nanoseconds per tick (default 1 000 000). - pub timestamp_scale: u64, - /// Start a new cluster at the next video keyframe (or any frame without video) after this long. - pub cluster_duration_ns: u64, - pub lacing: LacingMode, - /// Maximum frames per laced block. - pub max_lace_frames: usize, - pub title: Option, - pub writing_app: String, -} - -impl Default for MuxOptions { - fn default() -> Self { - Self { - doc_type: "matroska".into(), - timestamp_scale: 1_000_000, - cluster_duration_ns: 1_000_000_000, - lacing: LacingMode::None, - max_lace_frames: 8, - title: None, - writing_app: "filmcraft".into(), - } - } -} - -pub(crate) fn el(out: &mut Vec, id: u32, data: &[u8]) { - write_id(out, id); - write_size(out, data.len() as u64, 1); - out.extend_from_slice(data); -} - -pub(crate) fn el_uint(out: &mut Vec, id: u32, v: u64) { - let n = (8 - v.leading_zeros() as usize / 8).max(1); - el(out, id, &v.to_be_bytes()[8 - n..]); -} - -pub(crate) fn el_float(out: &mut Vec, id: u32, v: f64) { - el(out, id, &v.to_be_bytes()); -} - -pub(crate) fn el_str(out: &mut Vec, id: u32, s: &str) { - el(out, id, s.as_bytes()); -} - -fn void(out: &mut Vec, total: usize) { - debug_assert!(total >= 2); - let sl = if total - 2 <= 126 { 1 } else { 8 }; - write_id(out, VOID); - write_size(out, (total - 1 - sl) as u64, sl); - out.resize(out.len() + total - 1 - sl, 0); -} - -#[derive(Clone, Debug)] -struct Pending { - track: usize, - ts: i64, - frames: Vec>, -} - -const SEEK_HEAD_SPACE: usize = 128; - -/// Streaming Matroska/WebM writer. -pub struct MkvWriter { - w: W, - opts: MuxOptions, - tracks: Vec, - segment_size_pos: u64, - segment_data_start: u64, - seek_head_pos: u64, - duration_pos: u64, - info_pos: u64, - tracks_pos: u64, - cluster: Vec, - cluster_ts: Option, - cluster_start_ns: i64, - pending: Option, - cues: Vec<(u64, u64, u64)>, // (time ticks, track number, cluster position) - cued_tracks_in_cluster: Vec, - end_ns: i64, - has_video: bool, -} - -impl MkvWriter { - pub fn new(mut w: W, tracks: Vec, opts: MuxOptions) -> Result { - if tracks.is_empty() || opts.timestamp_scale == 0 { - return Err(Error::Invalid("muxer needs at least one track and a non-zero timestamp scale".into())); - } - let mut out = Vec::new(); - let mut h = Vec::new(); - el_uint(&mut h, EBML_VERSION, 1); - el_uint(&mut h, EBML_READ_VERSION, 1); - el_uint(&mut h, EBML_MAX_ID_LENGTH, 4); - el_uint(&mut h, EBML_MAX_SIZE_LENGTH, 8); - el_str(&mut h, DOC_TYPE, &opts.doc_type); - el_uint(&mut h, DOC_TYPE_VERSION, 4); - el_uint(&mut h, DOC_TYPE_READ_VERSION, 2); - el(&mut out, EBML, &h); - write_id(&mut out, SEGMENT); - let base = w.stream_position()?; - let segment_size_pos = base + out.len() as u64; - write_unknown_size(&mut out, 8); - let segment_data_start = base + out.len() as u64; - let seek_head_pos = segment_data_start; - void(&mut out, SEEK_HEAD_SPACE); - // Info - let info_pos = base + out.len() as u64; - let mut info = Vec::new(); - el_uint(&mut info, TIMESTAMP_SCALE, opts.timestamp_scale); - el_str(&mut info, MUXING_APP, "filmcraft-matroska"); - el_str(&mut info, WRITING_APP, &opts.writing_app); - if let Some(t) = &opts.title { - el_str(&mut info, TITLE, t); - } - let dur_off_in_info = info.len() + 2 + 1; // ID (2) + size (1) - el_float(&mut info, DURATION, 0.0); - write_id(&mut out, INFO); - write_size(&mut out, info.len() as u64, 1); - let duration_pos = base + out.len() as u64 + dur_off_in_info as u64; - out.extend_from_slice(&info); - // Tracks - let tracks_pos = base + out.len() as u64; - let mut tr = Vec::new(); - for (i, t) in tracks.iter().enumerate() { - let mut e = Vec::new(); - el_uint(&mut e, TRACK_NUMBER, i as u64 + 1); - el_uint(&mut e, TRACK_UID, 0x1000 + i as u64 + 1); - let ty = match t.kind { - TrackKind::Video => 1, - TrackKind::Audio => 2, - TrackKind::Subtitle => 0x11, - TrackKind::Complex => 3, - TrackKind::Logo => 0x10, - TrackKind::Buttons => 0x12, - TrackKind::Control => 0x20, - TrackKind::Metadata => 0x21, - TrackKind::Other(n) => n as u64, - }; - el_uint(&mut e, TRACK_TYPE, ty); - el_uint(&mut e, FLAG_LACING, (t.kind == TrackKind::Audio && opts.lacing != LacingMode::None) as u64); - if let Some(l) = &t.language { - el_str(&mut e, LANGUAGE, l); - } - if let Some(n) = &t.name { - el_str(&mut e, NAME, n); - } - el_str(&mut e, CODEC_ID, &t.codec_id); - if !t.codec_private.is_empty() { - el(&mut e, CODEC_PRIVATE, &t.codec_private); - } - if let Some(d) = t.default_duration_ns { - el_uint(&mut e, DEFAULT_DURATION, d); - } - if t.codec_delay_ns > 0 { - el_uint(&mut e, CODEC_DELAY, t.codec_delay_ns); - } - if t.seek_pre_roll_ns > 0 { - el_uint(&mut e, SEEK_PRE_ROLL, t.seek_pre_roll_ns); - } - if let Some((pw, ph)) = t.video_size { - let mut v = Vec::new(); - el_uint(&mut v, PIXEL_WIDTH, pw as u64); - el_uint(&mut v, PIXEL_HEIGHT, ph as u64); - el(&mut e, VIDEO, &v); - } - if let Some((rate, ch, bits)) = t.audio { - let mut a = Vec::new(); - el_float(&mut a, SAMPLING_FREQUENCY, rate); - el_uint(&mut a, CHANNELS, ch); - if let Some(b) = bits { - el_uint(&mut a, BIT_DEPTH, b); - } - el(&mut e, AUDIO, &a); - } - el(&mut tr, TRACK_ENTRY, &e); - } - el(&mut out, TRACKS, &tr); - w.write_all(&out)?; - let has_video = tracks.iter().any(|t| t.kind == TrackKind::Video); - let n = tracks.len(); - Ok(Self { - w, - opts, - tracks, - segment_size_pos, - segment_data_start, - seek_head_pos, - duration_pos, - info_pos, - tracks_pos, - cluster: Vec::new(), - cluster_ts: None, - cluster_start_ns: 0, - pending: None, - cues: Vec::new(), - cued_tracks_in_cluster: vec![false; n], - end_ns: 0, - has_video, - }) - } - - /// Add one frame. `pts_ns` is the presentation time (codec delay is added back for storage); - /// frames must be supplied in the desired file order. `duration_ns` forces a BlockGroup with a - /// BlockDuration when it differs from the track's DefaultDuration. - pub fn write_frame(&mut self, track: usize, pts_ns: i64, keyframe: bool, data: &[u8], duration_ns: Option) -> Result<()> { - let spec = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - // the Matroska ProRes mapping stores frames without the 8-byte `size`+`icpf` header - let data = if spec.codec_id == "V_PRORES" && data.get(4..8) == Some(b"icpf") { &data[8..] } else { data }; - let scale = self.opts.timestamp_scale as i64; - let stored_ns = pts_ns + spec.codec_delay_ns as i64; - if stored_ns < 0 { - return Err(Error::Invalid("negative timestamp".into())); - } - let ts = (stored_ns + scale / 2) / scale; - let dur_end = pts_ns + duration_ns.or(spec.default_duration_ns).unwrap_or(0) as i64; - self.end_ns = self.end_ns.max(dur_end).max(pts_ns); - let is_video = spec.kind == TrackKind::Video; - let lacing = spec.kind == TrackKind::Audio && self.opts.lacing != LacingMode::None; - let explicit_dur = duration_ns.filter(|&d| Some(d) != spec.default_duration_ns); - // new cluster? - let start_new = match self.cluster_ts { - None => true, - Some(cts) => { - let long = stored_ns - self.cluster_start_ns >= self.opts.cluster_duration_ns as i64; - (long && (!self.has_video || (is_video && keyframe))) || ts - cts > 30_000 || ts < cts - } - }; - if start_new { - self.flush_cluster()?; - self.cluster_ts = Some(ts); - self.cluster_start_ns = stored_ns; - el_uint(&mut self.cluster, TIMESTAMP, ts as u64); - } - let cts = self.cluster_ts.unwrap_or(0); - // cue: video keyframes, or the first frame of each track per cluster when there is no video - if (is_video && keyframe) || (!self.has_video && !self.cued_tracks_in_cluster[track]) { - self.cued_tracks_in_cluster[track] = true; - let pos = self.w.stream_position()? - self.segment_data_start; - if self.cues.last().is_none_or(|c| c.2 != pos || c.1 != track as u64 + 1) { - self.cues.push((ts as u64, track as u64 + 1, pos)); - } - } - if lacing && explicit_dur.is_none() { - let same = self.pending.as_ref().is_some_and(|p| p.track == track && p.frames.len() < self.opts.max_lace_frames); - if !same { - self.flush_pending(); - } - match &mut self.pending { - Some(p) => p.frames.push(data.to_vec()), - None => self.pending = Some(Pending { track, ts: ts - cts, frames: vec![data.to_vec()] }), - } - return Ok(()); - } - self.flush_pending(); - let mut block = Vec::new(); - write_size(&mut block, track as u64 + 1, 1); - block.extend_from_slice(&((ts - cts) as i16).to_be_bytes()); - if let Some(d) = explicit_dur { - block.push(0); - block.extend_from_slice(data); - let mut g = Vec::new(); - el(&mut g, BLOCK, &block); - el_uint(&mut g, BLOCK_DURATION, (d as i64 / scale) as u64); - if !keyframe { - el(&mut g, REFERENCE_BLOCK, &[0xFF]); - } - el(&mut self.cluster, BLOCK_GROUP, &g); - } else { - block.push(if keyframe { 0x80 } else { 0 }); - block.extend_from_slice(data); - el(&mut self.cluster, SIMPLE_BLOCK, &block); - } - Ok(()) - } - - fn flush_pending(&mut self) { - let Some(p) = self.pending.take() else { return }; - let mut block = Vec::new(); - write_size(&mut block, p.track as u64 + 1, 1); - block.extend_from_slice(&(p.ts as i16).to_be_bytes()); - let n = p.frames.len(); - let mode = if n == 1 { LacingMode::None } else { self.opts.lacing }; - let fixed = p.frames.iter().all(|f| f.len() == p.frames[0].len()); - match mode { - LacingMode::None => block.push(0x80), - LacingMode::Xiph => { - block.push(0x80 | 0x02); - block.push((n - 1) as u8); - for f in &p.frames[..n - 1] { - let mut s = f.len(); - while s >= 255 { - block.push(255); - s -= 255; - } - block.push(s as u8); - } - } - LacingMode::FixedOrEbml if fixed => { - block.push(0x80 | 0x04); - block.push((n - 1) as u8); - } - LacingMode::Ebml | LacingMode::FixedOrEbml => { - block.push(0x80 | 0x06); - block.push((n - 1) as u8); - write_size(&mut block, p.frames[0].len() as u64, 1); - for i in 1..n - 1 { - let d = p.frames[i].len() as i64 - p.frames[i - 1].len() as i64; - // signed VINT: smallest length whose range holds d - let mut len = 1; - while len < 8 && d.unsigned_abs() >= (1u64 << (7 * len - 1)) - 1 { - len += 1; - } - let v = (d + (1i64 << (7 * len - 1)) - 1) as u64 | (1u64 << (7 * len)); - block.extend_from_slice(&v.to_be_bytes()[8 - len..]); - } - } - } - for f in &p.frames { - block.extend_from_slice(f); - } - el(&mut self.cluster, SIMPLE_BLOCK, &block); - } - - fn flush_cluster(&mut self) -> Result<()> { - self.flush_pending(); - if self.cluster_ts.is_some() { - let mut out = Vec::new(); - el(&mut out, CLUSTER, &self.cluster); - self.w.write_all(&out)?; - } - self.cluster.clear(); - self.cluster_ts = None; - self.cued_tracks_in_cluster.iter_mut().for_each(|c| *c = false); - Ok(()) - } - - /// Write Cues, back-patch SeekHead / Segment size / Duration, and return the writer. - pub fn finish(mut self) -> Result { - self.flush_cluster()?; - let cues_pos = self.w.stream_position()?; - let mut cues = Vec::new(); - for &(time, track, pos) in &self.cues { - let mut ctp = Vec::new(); - el_uint(&mut ctp, CUE_TRACK, track); - el_uint(&mut ctp, CUE_CLUSTER_POSITION, pos); - let mut cp = Vec::new(); - el_uint(&mut cp, CUE_TIME, time); - el(&mut cp, CUE_TRACK_POSITIONS, &ctp); - el(&mut cues, CUE_POINT, &cp); - } - let has_cues = !cues.is_empty(); - if has_cues { - let mut out = Vec::new(); - el(&mut out, CUES, &cues); - self.w.write_all(&out)?; - } - let end = self.w.stream_position()?; - // SeekHead - let mut sh = Vec::new(); - let mut entries = vec![(INFO, self.info_pos), (TRACKS, self.tracks_pos)]; - if has_cues { - entries.push((CUES, cues_pos)); - } - for (id, pos) in entries { - let mut s = Vec::new(); - let mut idb = Vec::new(); - write_id(&mut idb, id); - el(&mut s, SEEK_ID, &idb); - el_uint(&mut s, SEEK_POSITION, pos - self.segment_data_start); - el(&mut sh, SEEK, &s); - } - let mut out = Vec::new(); - el(&mut out, SEEK_HEAD, &sh); - if out.len() + 2 > SEEK_HEAD_SPACE { - return Err(Error::Invalid("SeekHead does not fit its reserved space".into())); - } - let rest = SEEK_HEAD_SPACE - out.len(); - void(&mut out, rest); - self.w.seek(SeekFrom::Start(self.seek_head_pos))?; - self.w.write_all(&out)?; - // Duration - let dur = self.end_ns as f64 / self.opts.timestamp_scale as f64; - self.w.seek(SeekFrom::Start(self.duration_pos))?; - self.w.write_all(&dur.to_be_bytes())?; - // Segment size - let mut sz = Vec::new(); - write_size(&mut sz, end - self.segment_data_start, 8); - self.w.seek(SeekFrom::Start(self.segment_size_pos))?; - self.w.write_all(&sz)?; - self.w.seek(SeekFrom::Start(end))?; - self.w.flush()?; - Ok(self.w) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Demuxer, TrackKind}; - use std::io::Cursor; - - fn roundtrip(lacing: LacingMode) { - let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG"); - v.video_size = Some((16, 16)); - v.default_duration_ns = Some(40_000_000); - let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT"); - a.audio = Some((48000.0, 1, Some(16))); - a.default_duration_ns = Some(10_000_000); - let mut s = TrackSpec::new(TrackKind::Subtitle, "S_TEXT/UTF8"); - s.language = Some("fre".into()); - let opts = MuxOptions { lacing, ..Default::default() }; - let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![v, a, s], opts).unwrap(); - let mut expect = Vec::new(); - for i in 0..50i64 { - let vf = vec![i as u8; 100 + i as usize]; - m.write_frame(0, i * 40_000_000, i % 10 == 0, &vf, None).unwrap(); - expect.push((0usize, i * 40, vf, i % 10 == 0)); - for k in 0..4 { - let n = if lacing == LacingMode::FixedOrEbml { 960 } else { 900 + (i as usize * 7 + k * 13) % 300 }; - let af = vec![(i * 4 + k as i64) as u8; n]; - let t = i * 40 + k as i64 * 10; - m.write_frame(1, t * 1_000_000, true, &af, None).unwrap(); - expect.push((1, t, af, true)); - } - if i % 20 == 5 { - m.write_frame(2, i * 40_000_000, true, b"hello", Some(500_000_000)).unwrap(); - expect.push((2, i * 40, b"hello".to_vec(), true)); - } - } - let bytes = m.finish().unwrap().into_inner(); - let mut d = Demuxer::from_slice(&bytes).unwrap(); - assert!(d.file().warnings.is_empty(), "{:?}", d.file().warnings); - assert_eq!(d.tracks()[2].language, "fre"); - assert!(!d.file().cues.is_empty()); - assert_eq!(d.duration_ns(), Some(2_300_000_000)); - let got: Vec<_> = d.by_ref().map(|p| p.unwrap()).collect(); - assert_eq!(got.len(), expect.len()); - for (g, e) in got.iter().zip(&expect) { - assert_eq!((g.track, g.pts, &g.data, g.keyframe), (e.0, e.1, &e.2, e.3)); - } - let sub = got.iter().find(|p| p.track == 2).unwrap(); - assert_eq!(sub.duration, 500); - // the index agrees with the packet stream - let f = d.file(); - for (ti, t) in f.tracks.iter().enumerate() { - let pk: Vec<_> = got.iter().filter(|p| p.track == ti).collect(); - assert_eq!(t.samples.len(), pk.len()); - for (i, p) in pk.iter().enumerate() { - assert_eq!(f.read_sample(&bytes[..], ti, i).unwrap(), p.data); - assert_eq!(t.samples[i].pts, p.pts); - } - } - // seeking - let sp = d.seek(0, 1_230_000_000).unwrap(); - assert_eq!(sp.pts, 1200); - let p = d.next_packet().unwrap().unwrap(); - assert_eq!((p.track, p.pts, p.keyframe), (0, 1200, true)); - let sp = d.seek(1, 1_235_000_000).unwrap(); - assert_eq!(sp.pts, 1230); - let p = d.next_packet().unwrap().unwrap(); - assert_eq!((p.track, p.pts), (1, 1230)); - } - - #[test] - fn roundtrip_unlaced() { - roundtrip(LacingMode::None); - } - #[test] - fn roundtrip_xiph() { - roundtrip(LacingMode::Xiph); - } - #[test] - fn roundtrip_ebml() { - roundtrip(LacingMode::Ebml); - } - #[test] - fn roundtrip_fixed() { - roundtrip(LacingMode::FixedOrEbml); - } -} diff --git a/crates/matroska/src/source.rs b/crates/matroska/src/source.rs deleted file mode 100644 index da10d89ff..000000000 --- a/crates/matroska/src/source.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Random-access byte sources for the demuxer. - -use std::cell::RefCell; -use std::io::{self, Read, Seek, SeekFrom}; -use std::sync::Arc; - -/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). -/// -/// Same shape as `filmcraft_isobmff::ByteSource`, so one adapter can serve both demuxers. -pub trait ByteSource { - /// Total length in bytes. - fn len(&self) -> u64; - /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - /// True if the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -fn eof() -> io::Error { - io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") -} - -impl ByteSource for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let start = usize::try_from(offset).map_err(|_| eof())?; - let end = start.checked_add(buf.len()).ok_or_else(eof)?; - let src = self.get(start..end).ok_or_else(eof)?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ByteSource for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ByteSource for &T { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Arc { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Box { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl ByteSource for std::fs::File { - fn len(&self) -> u64 { - self.metadata().map(|m| m.len()).unwrap_or(0) - } - #[cfg(unix)] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) - } - #[cfg(windows)] - fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { - use std::os::windows::fs::FileExt; - while !buf.is_empty() { - let n = self.seek_read(buf, offset)?; - if n == 0 { - return Err(eof()); - } - offset += n as u64; - buf = &mut buf[n..]; - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let mut f = self; - f.seek(SeekFrom::Start(offset))?; - f.read_exact(buf) - } -} - -/// Adapts any `Read + Seek` (e.g. `BufReader`, `Cursor>`) into a [`ByteSource`]. -/// -/// The length is taken once at construction; the stream position is tracked so sequential reads -/// do not issue redundant seeks. -pub struct ReadSeekSource { - inner: RefCell<(R, u64)>, - len: u64, -} - -impl ReadSeekSource { - /// Wrap a reader; seeks to the end once to learn the length. - pub fn new(mut r: R) -> io::Result { - let len = r.seek(SeekFrom::End(0))?; - Ok(Self { inner: RefCell::new((r, len)), len }) - } - /// Unwrap the reader. - pub fn into_inner(self) -> R { - self.inner.into_inner().0 - } -} - -impl ByteSource for ReadSeekSource { - fn len(&self) -> u64 { - self.len - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - if offset.checked_add(buf.len() as u64).is_none_or(|e| e > self.len) { - return Err(eof()); - } - let mut g = self.inner.borrow_mut(); - let (r, pos) = &mut *g; - if *pos != offset { - r.seek(SeekFrom::Start(offset))?; - *pos = offset; - } - let res = r.read_exact(buf); - match res { - Ok(()) => { - *pos = offset + buf.len() as u64; - Ok(()) - } - Err(e) => { - *pos = u64::MAX; // unknown; force a seek next time - Err(e) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_and_reader_sources() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(1, &mut b).unwrap(); - assert_eq!(b, [2, 3]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let r = ReadSeekSource::new(io::Cursor::new(v.clone())).unwrap(); - assert_eq!(r.len(), 4); - r.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - r.read_at(0, &mut b).unwrap(); - assert_eq!(b, [1, 2]); - assert!(r.read_at(3, &mut b).is_err()); - } -} diff --git a/crates/matroska/src/synthetic_tests.rs b/crates/matroska/src/synthetic_tests.rs deleted file mode 100644 index f10df3ba8..000000000 --- a/crates/matroska/src/synthetic_tests.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Hand-built files exercising features FFmpeg does not write: unknown-size Segment/Clusters, -//! header stripping, BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32. - -use crate::codec::Codec; -use crate::ebml::{self, write_id, write_unknown_size}; -use crate::ids::*; -use crate::mux::{el, el_float, el_str, el_uint}; -use crate::track::ContentEncoding; -use crate::{Demuxer, OpenOptions, Packet, open_with}; - -fn block(track: u8, rel: i16, flags: u8, data: &[u8]) -> Vec { - let mut b = vec![0x80 | track]; - b.extend_from_slice(&rel.to_be_bytes()); - b.push(flags); - b.extend_from_slice(data); - b -} - -fn synthetic() -> Vec { - let mut f = Vec::new(); - let mut h = Vec::new(); - el_str(&mut h, DOC_TYPE, "matroska"); - el_uint(&mut h, DOC_TYPE_VERSION, 4); - el(&mut f, EBML, &h); - write_id(&mut f, SEGMENT); - write_unknown_size(&mut f, 8); - // Info with a correct CRC-32 and a Void - let mut info = Vec::new(); - el_uint(&mut info, TIMESTAMP_SCALE, 1_000_000); - el_str(&mut info, TITLE, "Synthetic"); - el(&mut info, VOID, &[0; 3]); - let mut crc_info = Vec::new(); - el(&mut crc_info, CRC32, &ebml::crc32(&info).to_le_bytes()); - crc_info.extend_from_slice(&info); - el(&mut f, INFO, &crc_info); - // Tracks - let mut v = Vec::new(); - el_uint(&mut v, TRACK_NUMBER, 1); - el_uint(&mut v, TRACK_UID, 11); - el_uint(&mut v, TRACK_TYPE, 1); - el_str(&mut v, CODEC_ID, "V_MPEG4/ISO/AVC"); - el(&mut v, CODEC_PRIVATE, &[1, 0x64, 0, 0x1F]); - el_uint(&mut v, DEFAULT_DURATION, 40_000_000); - let mut mm = Vec::new(); - for (id, val) in [(PRIMARY_R_X, 0.708), (PRIMARY_R_Y, 0.292), (PRIMARY_G_X, 0.17), (PRIMARY_G_Y, 0.797), (PRIMARY_B_X, 0.131), (PRIMARY_B_Y, 0.046)] { - el_float(&mut mm, id, val); - } - el_float(&mut mm, WHITE_POINT_X, 0.3127); - el_float(&mut mm, WHITE_POINT_Y, 0.329); - el_float(&mut mm, LUMINANCE_MAX, 1000.0); - el_float(&mut mm, LUMINANCE_MIN, 0.0001); - let mut col = Vec::new(); - el_uint(&mut col, MATRIX_COEFFICIENTS, 9); - el_uint(&mut col, RANGE, 2); - el_uint(&mut col, TRANSFER_CHARACTERISTICS, 16); - el_uint(&mut col, PRIMARIES, 9); - el_uint(&mut col, MAX_CLL, 1000); - el_uint(&mut col, MAX_FALL, 400); - el(&mut col, MASTERING_METADATA, &mm); - let mut vid = Vec::new(); - el_uint(&mut vid, PIXEL_WIDTH, 1440); - el_uint(&mut vid, PIXEL_HEIGHT, 1080); - el_uint(&mut vid, DISPLAY_WIDTH, 1920); - el_uint(&mut vid, DISPLAY_HEIGHT, 1080); - el(&mut vid, COLOUR, &col); - // rectangular, a quarter turn clockwise (portrait phone video) - let mut proj = Vec::new(); - el_uint(&mut proj, PROJECTION_TYPE, 0); - el_float(&mut proj, PROJECTION_POSE_ROLL, -90.0); - el(&mut vid, PROJECTION, &proj); - el(&mut v, VIDEO, &vid); - // header stripping: every frame starts with 00 00 00 01 - let mut comp = Vec::new(); - el_uint(&mut comp, CONTENT_COMP_ALGO, 3); - el(&mut comp, CONTENT_COMP_SETTINGS, &[0, 0, 0, 1]); - let mut enc = Vec::new(); - el(&mut enc, CONTENT_COMPRESSION, &comp); - let mut encs = Vec::new(); - el(&mut encs, CONTENT_ENCODING, &enc); - el(&mut v, CONTENT_ENCODINGS, &encs); - let mut a = Vec::new(); - el_uint(&mut a, TRACK_NUMBER, 2); - el_uint(&mut a, TRACK_TYPE, 2); - el_str(&mut a, CODEC_ID, "A_AAC/MPEG4/LC/SBR"); - el_str(&mut a, LANGUAGE, "ger"); - let mut au = Vec::new(); - el_float(&mut au, SAMPLING_FREQUENCY, 24000.0); - el_float(&mut au, OUTPUT_SAMPLING_FREQUENCY, 48000.0); - el_uint(&mut au, CHANNELS, 2); - el(&mut a, AUDIO, &au); - let mut tr = Vec::new(); - el(&mut tr, TRACK_ENTRY, &v); - el(&mut tr, TRACK_ENTRY, &a); - el(&mut f, TRACKS, &tr); - // Chapters - let mut disp = Vec::new(); - el_str(&mut disp, CHAP_STRING, "Intro"); - let mut atom = Vec::new(); - el_uint(&mut atom, CHAPTER_UID, 5); - el_uint(&mut atom, CHAPTER_TIME_START, 0); - el_uint(&mut atom, CHAPTER_TIME_END, 40_000_000); - el(&mut atom, CHAPTER_DISPLAY, &disp); - let mut ed = Vec::new(); - el(&mut ed, CHAPTER_ATOM, &atom); - let mut ch = Vec::new(); - el(&mut ch, EDITION_ENTRY, &ed); - el(&mut f, CHAPTERS, &ch); - // Attachments - let mut af = Vec::new(); - el_str(&mut af, FILE_NAME, "font.ttf"); - el_str(&mut af, FILE_MEDIA_TYPE, "font/ttf"); - el_uint(&mut af, FILE_UID, 77); - el(&mut af, FILE_DATA, &[9; 100]); - let mut att = Vec::new(); - el(&mut att, ATTACHED_FILE, &af); - el(&mut f, ATTACHMENTS, &att); - // Cluster 1 (unknown size): keyframe SimpleBlock, BlockGroup with ReferenceBlock + duration - write_id(&mut f, CLUSTER); - write_unknown_size(&mut f, 1); - el_uint(&mut f, TIMESTAMP, 1000); - el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 1, 2])); - el(&mut f, SIMPLE_BLOCK, &block(2, 5, 0x80, &[0xAA; 10])); - let mut g = Vec::new(); - el(&mut g, BLOCK, &block(1, 40, 0, &[0x41, 3])); - el(&mut g, REFERENCE_BLOCK, &[0xD8]); - el_uint(&mut g, BLOCK_DURATION, 80); - el(&mut f, BLOCK_GROUP, &g); - el(&mut f, SIMPLE_BLOCK, &block(9, 0, 0x80, &[1])); // unknown track: ignored - // Cluster 2 (unknown size), ended by Tags - write_id(&mut f, CLUSTER); - write_unknown_size(&mut f, 8); - el_uint(&mut f, TIMESTAMP, 1120); - el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 4])); - let mut st = Vec::new(); - el_str(&mut st, TAG_NAME, "ARTIST"); - el_str(&mut st, TAG_STRING, "Nobody"); - let mut tg = Vec::new(); - let mut tgt = Vec::new(); - el_uint(&mut tgt, TAG_TRACK_UID, 11); - el(&mut tg, TARGETS, &tgt); - el(&mut tg, SIMPLE_TAG, &st); - let mut tags = Vec::new(); - el(&mut tags, TAG, &tg); - el(&mut f, TAGS, &tags); - f -} - -#[test] -fn synthetic_file() { - let bytes = synthetic(); - let f = open_with(&bytes[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); - assert!(f.live); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - assert_eq!((f.crc_checked, f.crc_errors.len()), (1, 0)); - assert_eq!(f.info.title.as_deref(), Some("Synthetic")); - let v = &f.tracks[0]; - assert!(matches!(&v.codec, Codec::Avc { avcc } if avcc[1] == 0x64)); - let vi = v.video.as_ref().unwrap(); - assert_eq!(vi.pixel_aspect(), (4, 3)); - assert_eq!(vi.projection.as_ref().map(|p| p.roll), Some(-90.0)); - assert_eq!(vi.display_rotation(), Some(1)); - let c = vi.colour.as_ref().unwrap(); - assert!(c.full_range()); - assert_eq!((c.matrix_coefficients, c.transfer_characteristics, c.primaries, c.max_cll, c.max_fall), (Some(9), Some(16), Some(9), Some(1000), Some(400))); - let m = c.mastering.as_ref().unwrap(); - assert_eq!(m.primaries[1], (0.17, 0.797)); - assert_eq!(m.white_point, (0.3127, 0.329)); - assert_eq!((m.luminance_max, m.luminance_min), (1000.0, 0.0001)); - assert_eq!(v.encodings, vec![ContentEncoding::HeaderStripping { bytes: vec![0, 0, 0, 1], scope: 1 }]); - // legacy AAC ID: ASC synthesised with explicit SBR signalling (LC, 24 kHz, stereo → 48 kHz) - assert_eq!(f.tracks[1].codec, Codec::Aac { asc: vec![0x13, 0x10, 0x56, 0xE5, 0x98] }); - assert_eq!(f.tracks[1].language, "ger"); - assert_eq!(f.chapters[0].chapters[0].title(), Some("Intro")); - assert_eq!(f.chapters[0].chapters[0].end_ns, Some(40_000_000)); - assert_eq!(f.attachments.len(), 1); - let at = &f.attachments[0]; - assert_eq!((at.name.as_str(), at.media_type.as_str(), at.uid), ("font.ttf", "font/ttf", 77)); - assert_eq!(&bytes[at.data_offset as usize..][..100], &[9; 100]); - assert_eq!(f.tags[0].targets.track_uids, vec![11]); - assert_eq!(f.tags[0].simple[0].string.as_deref(), Some("Nobody")); - assert_eq!(f.clusters.len(), 2); - assert_eq!(v.samples.len(), 3); - assert_eq!(v.keyframes(), &[0, 2]); - - let d = Demuxer::from_slice(&bytes).unwrap(); - let p: Vec = d.map(|p| p.unwrap()).collect(); - assert_eq!(p.len(), 4); - assert_eq!((p[0].track, p[0].pts, p[0].keyframe, p[0].duration), (0, 1000, true, 40)); - assert_eq!(p[0].data, [0, 0, 0, 1, 0x65, 1, 2]); - assert_eq!((p[1].track, p[1].pts, p[1].data.len()), (1, 1005, 10)); - assert_eq!((p[2].pts, p[2].keyframe, p[2].duration), (1040, false, 80)); - assert_eq!(p[2].data, [0, 0, 0, 1, 0x41, 3]); - assert_eq!((p[3].pts, p[3].pts_ns), (1120, 1_120_000_000)); - assert_eq!(f.read_sample(&bytes[..], 0, 1).unwrap(), p[2].data); - - // CRC mismatch is reported, not fatal - let mut bad = bytes.clone(); - let title = bad.windows(9).position(|w| w == b"Synthetic").unwrap(); - bad[title] = b's'; - let f = open_with(&bad[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); - assert_eq!(f.crc_errors.len(), 1); - - // seeking without index or cues builds the index - let mut d = Demuxer::with_options(&bytes[..], OpenOptions { index: false, verify_crc: false }).unwrap(); - assert_eq!(d.file().tracks.len(), 2); - assert_eq!(d.seek(0, 1_100_000_000).unwrap().pts, 1000); - assert_eq!(d.seek(0, 1_130_000_000).unwrap().pts, 1120); - assert_eq!(d.next_packet().unwrap().unwrap().pts, 1120); - assert!(d.file().indexed); -} diff --git a/crates/matroska/src/track.rs b/crates/matroska/src/track.rs deleted file mode 100644 index ecdecabe8..000000000 --- a/crates/matroska/src/track.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! Track descriptions (RFC 9559 §5.1.4). - -use crate::codec::Codec; - -/// Track type (`TrackType`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrackKind { - Video, - Audio, - Complex, - Logo, - Subtitle, - Buttons, - Control, - Metadata, - Other(u8), -} - -impl TrackKind { - pub(crate) fn from_type(t: u64) -> TrackKind { - match t { - 1 => TrackKind::Video, - 2 => TrackKind::Audio, - 3 => TrackKind::Complex, - 0x10 => TrackKind::Logo, - 0x11 => TrackKind::Subtitle, - 0x12 => TrackKind::Buttons, - 0x20 => TrackKind::Control, - 0x21 => TrackKind::Metadata, - n => TrackKind::Other(n as u8), - } - } -} - -/// SMPTE ST 2086 mastering display metadata (`MasteringMetadata`). Chromaticities are CIE 1931 xy, -/// luminance in cd/m². -#[derive(Clone, Debug, Default, PartialEq)] -pub struct MasteringMetadata { - /// Red, green, blue primaries as (x, y). - pub primaries: [(f64, f64); 3], - pub white_point: (f64, f64), - pub luminance_max: f64, - pub luminance_min: f64, -} - -/// `Colour` element. Code points use ITU-T H.273 values (as Matroska does). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Colour { - /// `MatrixCoefficients` (H.273; 2 = unspecified). - pub matrix_coefficients: Option, - pub bits_per_channel: Option, - pub chroma_subsampling_horz: Option, - pub chroma_subsampling_vert: Option, - pub cb_subsampling_horz: Option, - pub cb_subsampling_vert: Option, - pub chroma_siting_horz: Option, - pub chroma_siting_vert: Option, - /// `Range`: 0 unspecified, 1 broadcast (limited), 2 full, 3 defined by matrix/transfer. - pub range: Option, - /// `TransferCharacteristics` (H.273). - pub transfer_characteristics: Option, - /// `Primaries` (H.273). - pub primaries: Option, - /// Maximum content light level (cd/m²). - pub max_cll: Option, - /// Maximum frame-average light level (cd/m²). - pub max_fall: Option, - pub mastering: Option, -} - -impl Colour { - /// True if `Range` says full range. - pub fn full_range(&self) -> bool { - self.range == Some(2) - } -} - -/// `Video` element. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct VideoInfo { - pub pixel_width: u32, - pub pixel_height: u32, - /// `DisplayWidth`/`DisplayHeight`, in `display_unit` (0 = pixels, 3 = display aspect ratio). - pub display_width: Option, - pub display_height: Option, - pub display_unit: u64, - /// Crop (top, bottom, left, right) in pixels. - pub crop: (u32, u32, u32, u32), - /// `FlagInterlaced`: 0 undetermined, 1 interlaced, 2 progressive. - pub flag_interlaced: u64, - /// `FieldOrder` (0 progressive, 1 tff, 2 undetermined, 6 bff, 9/14 interleaved). - pub field_order: Option, - pub stereo_mode: Option, - /// `AlphaMode`: 1 if BlockAdditions carry alpha. - pub alpha_mode: u64, - /// `ColourSpace` FourCC (uncompressed video). - pub colour_space: Option<[u8; 4]>, - pub colour: Option, - pub projection: Option, -} - -/// `Projection` element (RFC 9559 §5.1.4.1.28.41): pose angles in degrees. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Projection { - /// 0 rectangular, 1 equirectangular, 2 cubemap, 3 mesh. - pub projection_type: u64, - /// Clockwise around the up vector. - pub yaw: f64, - /// Counter-clockwise around the right vector. - pub pitch: f64, - /// Counter-clockwise around the forward vector. - pub roll: f64, -} - -impl VideoInfo { - /// Clockwise quarter turns (0–3) to display a rectangular video, from `ProjectionPoseRoll` - /// (a counter-clockwise angle, so −90 is a quarter turn clockwise). None for other - /// projections, flips (non-zero yaw / pitch) or angles that are not a multiple of 90°. - pub fn display_rotation(&self) -> Option { - let Some(p) = &self.projection else { return Some(0) }; - if p.projection_type != 0 || p.yaw != 0.0 || p.pitch != 0.0 || p.roll % 90.0 != 0.0 { - return None; - } - Some((-(p.roll / 90.0) as i64).rem_euclid(4) as u8) - } - - /// Pixel aspect ratio derived from the display size (pixels or aspect-ratio units), reduced. - pub fn pixel_aspect(&self) -> (u32, u32) { - let pw = self.pixel_width.saturating_sub(self.crop.2.saturating_add(self.crop.3)) as u64; - let ph = self.pixel_height.saturating_sub(self.crop.0.saturating_add(self.crop.1)) as u64; - let (Some(dw), Some(dh)) = (self.display_width, self.display_height) else { return (1, 1) }; - if pw == 0 || ph == 0 || dw == 0 || dh == 0 || !matches!(self.display_unit, 0 | 3) { - return (1, 1); - } - // PAR = (dw/dh) / (pw/ph) - let (n, d) = (dw as u64 * ph, dh as u64 * pw); - let g = gcd(n, d); - ((n / g) as u32, (d / g) as u32) - } -} - -pub(crate) fn gcd(mut a: u64, mut b: u64) -> u64 { - while b != 0 { - (a, b) = (b, a % b); - } - a.max(1) -} - -/// `Audio` element. -#[derive(Clone, Debug, PartialEq)] -pub struct AudioInfo { - /// `SamplingFrequency` (Hz). - pub sampling_frequency: f64, - /// `OutputSamplingFrequency` (e.g. SBR output rate). - pub output_sampling_frequency: Option, - pub channels: u64, - pub bit_depth: Option, -} - -impl Default for AudioInfo { - fn default() -> Self { - Self { sampling_frequency: 8000.0, output_sampling_frequency: None, channels: 1, bit_depth: None } - } -} - -/// One `ContentEncoding`. -#[derive(Clone, Debug, PartialEq)] -pub enum ContentEncoding { - /// `ContentCompAlgo` 3: the given bytes were stripped from the start of every frame. - HeaderStripping { bytes: Vec, scope: u64 }, - /// Other compression (0 zlib, 1 bzlib, 2 lzo1x): not supported for reading frame data. - Compression { algo: u64, settings: Vec, scope: u64 }, - /// Encryption (not supported for reading frame data). - Encryption { scope: u64 }, -} - -/// One entry of a track's sample (frame) table. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Sample { - /// Byte offset of the (stored) frame data in the file. - pub offset: u64, - /// Stored size (excluding header-stripped bytes; see [`Track::sample_size`]). - pub size: u32, - /// Presentation timestamp in the track timebase (`TimestampScale` ticks). - pub pts: i64, - /// Duration in timebase ticks (from `BlockDuration` or `DefaultDuration`), 0 if unknown. - pub duration: u64, - pub keyframe: bool, - /// Index of the owning cluster in [`crate::MkvFile::clusters`]. - pub cluster: u32, - /// Offset of the block element (SimpleBlock / BlockGroup) header. - pub block_offset: u64, - /// Index of this frame within a laced block. - pub lace: u16, - /// (offset, size) of the block's `BlockAdditional` with `BlockAddID` 1, when it has one and - /// is not laced. In WebM this is the alpha layer of a VP8/VP9 track with `AlphaMode` 1: a - /// second, independently coded stream whose luma plane is the frame's alpha. - pub addition: Option<(u64, u32)>, -} - -/// A Matroska track (`TrackEntry`) plus, once indexed, its sample table. -#[derive(Clone, Debug)] -pub struct Track { - pub number: u64, - pub uid: u64, - pub kind: TrackKind, - pub enabled: bool, - pub default: bool, - pub forced: bool, - pub hearing_impaired: bool, - pub lacing: bool, - /// `DefaultDuration` in nanoseconds. - pub default_duration_ns: Option, - pub name: String, - /// `Language` (ISO 639-2, default `eng`). - pub language: String, - pub language_bcp47: Option, - pub codec_id: String, - pub codec_private: Vec, - pub codec_name: String, - pub codec: Codec, - /// `CodecDelay` in nanoseconds. - pub codec_delay_ns: u64, - /// `SeekPreRoll` in nanoseconds. - pub seek_pre_roll_ns: u64, - pub video: Option, - pub audio: Option, - pub encodings: Vec, - /// Timebase numerator/denominator in seconds: `TimestampScale / 1e9`, reduced. - pub timebase: (u64, u64), - /// Frame table (empty unless the file was opened with indexing). - pub samples: Vec, - pub(crate) sync: Vec, -} - -impl Track { - /// Bytes prepended to every frame by header-stripping compression, if any. - pub fn stripped_header(&self) -> Option<&[u8]> { - self.encodings.iter().find_map(|e| match e { - ContentEncoding::HeaderStripping { bytes, scope } if scope & 1 != 0 => Some(bytes.as_slice()), - _ => None, - }) - } - - /// True if frames can be returned verbatim or with header stripping undone. - pub fn frames_readable(&self) -> bool { - self.encodings.iter().all(|e| match e { - ContentEncoding::HeaderStripping { .. } => true, - ContentEncoding::Compression { scope, .. } | ContentEncoding::Encryption { scope } => scope & 1 == 0, - }) - } - - /// Bytes to put in front of a stored frame of `stored_size` bytes to get the codec frame: - /// header-stripped bytes, or for `V_PRORES` the 8-byte frame header (`size` + `icpf`) that the - /// Matroska ProRes mapping omits. - pub fn frame_prefix(&self, stored_size: u64) -> Vec { - let mut p = self.stripped_header().map(<[u8]>::to_vec).unwrap_or_default(); - if matches!(self.codec, Codec::ProRes { .. }) { - let total = stored_size + p.len() as u64 + 8; - let mut h = (total as u32).to_be_bytes().to_vec(); - h.extend_from_slice(b"icpf"); - h.extend_from_slice(&p); - p = h; - } - p - } - - /// Frame size of sample `i` as returned by `read_sample` / packets (stored size plus prefix). - pub fn sample_size(&self, i: usize) -> Option { - let s = self.samples.get(i)?; - Some(s.size as u64 + self.frame_prefix(s.size as u64).len() as u64) - } - - /// Indices of keyframe samples, ascending by file order. - pub fn keyframes(&self) -> &[u32] { - &self.sync - } - - /// Timebase ticks → nanoseconds. - pub fn ticks_to_ns(&self, t: i64) -> i64 { - (t as i128 * self.timebase.0 as i128 * 1_000_000_000 / self.timebase.1 as i128) as i64 - } - - /// Nanoseconds → timebase ticks (floor). - pub fn ns_to_ticks(&self, ns: i64) -> i64 { - let n = ns as i128 * self.timebase.1 as i128; - let d = self.timebase.0 as i128 * 1_000_000_000; - n.div_euclid(d) as i64 - } - - /// Index of the last sample with `pts <= t` (in file order), or the first sample. - pub fn sample_at_pts(&self, t: i64) -> Option { - if self.samples.is_empty() { - return None; - } - let mut best: Option = None; - for (i, s) in self.samples.iter().enumerate() { - if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) { - best = Some(i); - } - } - Some(best.unwrap_or(0)) - } - - /// Index of the keyframe with the greatest `pts <= t` (the first keyframe if none precedes `t`). - pub fn keyframe_before_pts(&self, t: i64) -> Option { - let mut best: Option = None; - for &k in &self.sync { - let s = &self.samples[k as usize]; - if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) { - best = Some(k as usize); - } - } - best.or_else(|| self.sync.first().map(|&k| k as usize)) - } - - /// Index of the nearest keyframe at or before sample `index` in file order. - pub fn sync_sample_before(&self, index: usize) -> usize { - match self.sync.binary_search(&(index as u32)) { - Ok(i) => self.sync[i] as usize, - Err(0) => 0, - Err(i) => self.sync[i - 1] as usize, - } - } -} - -#[cfg(test)] -mod rotation_tests { - use super::{Projection, VideoInfo}; - - fn with_roll(projection_type: u64, yaw: f64, pitch: f64, roll: f64) -> VideoInfo { - VideoInfo { projection: Some(Projection { projection_type, yaw, pitch, roll }), ..Default::default() } - } - - #[test] - fn display_rotation_maps_rectangular_roll_to_clockwise_quarter_turns() { - assert_eq!(VideoInfo::default().display_rotation(), Some(0), "no Projection: as stored"); - // ProjectionPoseRoll is counter-clockwise: -90 is a quarter turn clockwise - for (roll, turns) in [(0.0, 0), (-90.0, 1), (180.0, 2), (-180.0, 2), (90.0, 3), (-270.0, 3), (270.0, 1), (360.0, 0)] { - assert_eq!(with_roll(0, 0.0, 0.0, roll).display_rotation(), Some(turns), "roll {roll}"); - } - } - - #[test] - fn display_rotation_leaves_non_rectangular_flipped_and_odd_angles_alone() { - assert_eq!(with_roll(1, 0.0, 0.0, -90.0).display_rotation(), None, "equirectangular"); - assert_eq!(with_roll(0, 180.0, 0.0, 0.0).display_rotation(), None, "yaw flip"); - assert_eq!(with_roll(0, 0.0, 180.0, 0.0).display_rotation(), None, "pitch flip"); - assert_eq!(with_roll(0, 0.0, 0.0, 45.0).display_rotation(), None, "not a quarter turn"); - assert_eq!(with_roll(0, 0.0, 0.0, f64::NAN).display_rotation(), None, "malformed angle"); - } -} - -#[cfg(test)] -mod aspect_overflow_tests { - use super::VideoInfo; - - #[test] - fn hostile_crop_sums_do_not_overflow() { - let video = VideoInfo { - pixel_width: 640, - pixel_height: 480, - display_width: Some(640), - display_height: Some(480), - crop: (u32::MAX, 1, u32::MAX, 1), - ..Default::default() - }; - assert_eq!(video.pixel_aspect(), (1, 1)); - } - - #[test] - fn ordinary_pixel_aspect_is_unchanged() { - let video = VideoInfo { pixel_width: 1920, pixel_height: 1080, display_width: Some(1440), display_height: Some(1080), ..Default::default() }; - assert_eq!(video.pixel_aspect(), (3, 4)); - } -} diff --git a/crates/matroska/tests/oracle.rs b/crates/matroska/tests/oracle.rs index 5eaae0a03..f53b09978 100644 --- a/crates/matroska/tests/oracle.rs +++ b/crates/matroska/tests/oracle.rs @@ -58,8 +58,14 @@ fn compare(ffprobe: &Path, path: &Path) -> (MkvFile, Vec) { assert!((pts - p.pts).abs() <= p.lace as i64, "{ctx}: pts ffprobe {pts} vs {}", p.pts); pts_checked += 1; } + // The last audio or video frame of a track cut by `-t` is shorter than a codec frame. + // ffmpeg 8 writes a shortened BlockDuration there and reports the codec frame duration; a + // remux by our writer stores DefaultDuration and ffprobe reports the shortened one. So + // that frame's duration is not compared. A subtitle's duration is its cue's, and is. + let last_of_track = packets[i + 1..].iter().all(|q| q.track != p.track); if let Some(dur) = int(&f["duration"]) && p.duration != 0 + && (!last_of_track || file.tracks[p.track].kind == TrackKind::Subtitle) { assert!((dur - p.duration as i64).abs() <= 1, "{ctx}: duration ffprobe {dur} vs {}", p.duration); dur_checked += 1; @@ -226,11 +232,11 @@ fn live_stream_without_cues() { } #[test] -fn hevc_hdr_colour() { +fn hevc_hdr_color() { let Some((_, f, _)) = run("hevc_hdr.mkv") else { return }; let t = &f.tracks[0]; assert!(matches!(&t.codec, Codec::Hevc { hvcc } if hvcc.first() == Some(&1))); - let c = t.video.as_ref().unwrap().colour.as_ref().expect("Colour element"); + let c = t.video.as_ref().unwrap().color.as_ref().expect("Colour element"); assert_eq!(c.primaries, Some(9)); assert_eq!(c.transfer_characteristics, Some(16)); assert_eq!(c.matrix_coefficients, Some(9)); @@ -286,12 +292,14 @@ fn laced_remux() { let specs: Vec = file .tracks .iter() - .map(|t| { + .enumerate() + .map(|(ti, t)| { let mut s = TrackSpec::new(t.kind, t.codec_id.clone()); s.codec_private = t.codec_private.clone(); + // audio without a DefaultDuration gets its mean packet spacing, so it can lace s.default_duration_ns = t.default_duration_ns.or_else(|| { - // give AAC a DefaultDuration so every lace gets a timestamp - matches!(t.codec, Codec::Aac { .. }).then(|| (1024.0 * 1e9 / t.audio.as_ref().unwrap().sampling_frequency) as u64) + let pts: Vec = packets.iter().filter(|p| p.track == ti).map(|p| p.pts_ns).collect(); + (t.kind == TrackKind::Audio && pts.len() > 1).then(|| ((pts[pts.len() - 1] - pts[0]) / (pts.len() as i64 - 1)) as u64) }); s.video_size = t.video.as_ref().map(|v| (v.pixel_width, v.pixel_height)); s.audio = t.audio.as_ref().map(|a| (a.sampling_frequency, a.channels, a.bit_depth)); @@ -316,7 +324,7 @@ fn laced_remux() { assert_eq!(got.len(), packets.len()); for (a, b) in got.iter().zip(&packets) { assert_eq!(a.data, b.data, "{src} {mode:?}: payload"); - assert_eq!(a.track, b.track); + assert_eq!((a.track, a.pts), (b.track, b.pts), "{src} {mode:?}: timestamp"); } std::fs::remove_file(&outp).unwrap(); } From f6bd7a724faf31398e02cc05c180fc9fce409694 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:47:58 +0200 Subject: [PATCH 057/110] fix(media): validate HDR scanlines before frame allocation (#2957) --- crates/media/src/still/hdr.rs | 11 ++++++++++- crates/media/src/still/hdr/tests.rs | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/media/src/still/hdr.rs b/crates/media/src/still/hdr.rs index 4e0765bc3..12ff4e72d 100644 --- a/crates/media/src/still/hdr.rs +++ b/crates/media/src/still/hdr.rs @@ -197,8 +197,17 @@ pub fn decode(name: &str, b: &[u8]) -> Result { if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < (w as u64).saturating_mul(h as u64) { return Err(err(name, "truncated pixel data")); } - let mut px = vec![1f32; w * h * 4]; + // Validate every encoded scanline before allocating the complete float image. A short HDR + // header can otherwise request up to a GiB of zero-initialized pixels and only then fail on + // its first missing or malformed scanline. + let pixel_data = pos; let mut scan = vec![[0u8; 4]; n2]; + for _ in 0..n1 { + scanline(name, b, &mut pos, &mut scan)?; + } + + let mut px = vec![1f32; w * h * 4]; + pos = pixel_data; for i in 0..n1 { scanline(name, b, &mut pos, &mut scan)?; for (j, &p) in scan.iter().enumerate() { diff --git a/crates/media/src/still/hdr/tests.rs b/crates/media/src/still/hdr/tests.rs index 3e0b5564a..612b660bc 100644 --- a/crates/media/src/still/hdr/tests.rs +++ b/crates/media/src/still/hdr/tests.rs @@ -112,6 +112,9 @@ fn hostile_input_is_an_error_not_a_panic() { } assert!(decode("a.hdr", &file("-Y 2 +X 2", &[0; 8])).is_err()); assert!(decode("a.hdr", &file("-Y 99999999 +X 99999999", &[0; 16])).is_err()); + // Pass the coarse compressed-size check but fail scanline validation. This must be rejected + // before a 1 GiB float image is initialized. + assert!(decode("a.hdr", &file("-Y 67108864 +X 1", &[0; 16_384])).is_err()); assert!(decode("a.hdr", &file("-Y 0 +X 4", &[])).is_err()); assert!(decode("a.hdr", &file("-Y 2 -Y 2", &[0; 16])).is_err()); assert!(decode("a.hdr", &file("junk", &[0; 16])).is_err()); From 5d378caf2dffe511e99f7ee78a3fc3c58a83ab36 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:48:41 +0200 Subject: [PATCH 058/110] fix(captions): bound TTML XML nesting (#2955) --- crates/captions/src/ttml.rs | 68 +++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index bbbbc6628..93de581ea 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -39,6 +39,67 @@ const NS_XML: &str = "http://www.w3.org/XML/1998/namespace"; const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text"; const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation"; +/// TTML is normally only a few containers deep. Keep the XML parser and our recursive content +/// walkers safely below a native-stack-sized nesting level for untrusted caption files. +const MAX_XML_DEPTH: usize = 128; + +/// Count XML element nesting without constructing an XML tree. This deliberately only decides +/// where markup ends (respecting quoted attributes, comments, CDATA and processing instructions); +/// `roxmltree` remains responsible for XML syntax and matching tags. +fn check_xml_depth(text: &str) -> Result<()> { + let bytes = text.as_bytes(); + let mut i = 0; + let mut depth = 0usize; + while let Some(relative) = text[i..].find('<') { + let start = i + relative; + let rest = &text[start..]; + if rest.starts_with("") else { return Ok(()) }; + i = start + 4 + end + 3; + continue; + } + if rest.starts_with("") else { return Ok(()) }; + i = start + 9 + end + 3; + continue; + } + if rest.starts_with("") else { return Ok(()) }; + i = start + 2 + end + 2; + continue; + } + + let mut j = start + 1; + let mut quote = None; + while j < bytes.len() { + match (bytes[j], quote) { + (b'\'' | b'\"', None) => quote = Some(bytes[j]), + (b, Some(q)) if b == q => quote = None, + (b'>', None) => break, + _ => {} + } + j += 1; + } + if j == bytes.len() { + return Ok(()); + } + + if rest.starts_with(" MAX_XML_DEPTH { + return Err(Error::Syntax { line: 1, msg: format!("TTML XML nesting exceeds {MAX_XML_DEPTH} elements") }); + } + } + } + i = j + 1; + } + Ok(()) +} + fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars() { @@ -482,6 +543,7 @@ fn clean(s: &str) -> String { } pub fn parse(text: &str) -> Result { + check_xml_depth(text)?; let xml = roxmltree::Document::parse(text).map_err(|_| Error::NotFormat("TTML"))?; let root = xml.root_element(); if root.tag_name().name() != "tt" { @@ -649,6 +711,12 @@ mod tests { } } + #[test] + fn deeply_nested_ttml_is_rejected_before_xml_parsing() { + let xml = format!("{}

caption

{}
", "
".repeat(MAX_XML_DEPTH), "
".repeat(MAX_XML_DEPTH)); + assert!(matches!(parse(&xml), Err(Error::Syntax { msg, .. }) if msg.contains("nesting exceeds"))); + } + #[test] fn missing_timing_parameters_keep_defaults() { for (params, begin, end) in [ From 539c746058fe31dfa7769278bab9cdde4ab1758b Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:48:51 +0200 Subject: [PATCH 059/110] security(dng): bound tile decoding and IFD traversal (#2952) --- crates/dng/src/raw.rs | 20 ++++++++++---------- crates/dng/src/tiff.rs | 14 +++++++++++--- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/dng/src/raw.rs b/crates/dng/src/raw.rs index b1c448ffc..4e69e0944 100644 --- a/crates/dng/src/raw.rs +++ b/crates/dng/src/raw.rs @@ -1,14 +1,14 @@ //! The raw image data of a DNG: finding the main raw IFD, and reading its strips or tiles //! (uncompressed or lossless JPEG) into one array of 16-bit samples. -#[cfg(feature = "threads")] -use rayon::prelude::*; - use crate::tiff::{Ifd, Tiff, tags}; use crate::{Error, Result, ljpeg}; /// Largest raw image decoded, in samples (width × height × samples per pixel). pub const MAX_SAMPLES: usize = 1 << 27; +/// Decoded tiles are padded to tile boundaries, so their aggregate can exceed the useful image. +/// Keep that temporary work within the same bound as a complete decoded raw frame. +const MAX_DECODED_TILE_SAMPLES: usize = MAX_SAMPLES; /// Photometric interpretations of DNG raw data. pub const PHOTOMETRIC_CFA: u64 = 32803; @@ -225,23 +225,23 @@ pub fn read(t: &Tiff, ifd: &Ifd) -> Result { if total / 8 > coded { return Err(Error::Truncated); } + let mut decoded_samples = 0usize; for c in &chunks { let n = c.w.checked_mul(c.h).and_then(|v| v.checked_mul(spp)).ok_or(Error::TooLarge)?; - if n > MAX_SAMPLES { + decoded_samples = decoded_samples.checked_add(n).ok_or(Error::TooLarge)?; + if n > MAX_SAMPLES || decoded_samples > MAX_DECODED_TILE_SAMPLES { return Err(Error::TooLarge); } if n / 8 > c.bytes.len() { return Err(Error::Truncated); } } - #[cfg(feature = "threads")] - let decoded: Vec>> = chunks.par_iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); - #[cfg(not(feature = "threads"))] - let decoded: Vec>> = chunks.iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); let mut data = vec![0u16; total]; let row_len = l.width * spp; - for (c, d) in chunks.iter().zip(decoded) { - let d = d?; + for c in &chunks { + // Copy each padded tile immediately. Retaining every decoded tile until assembly lets + // repeated ranges multiply memory beyond the final raw frame's bounded allocation. + let d = decode_chunk(c, &l, t.big_endian)?; let cw = c.w.min(l.width - c.x); let ch = c.h.min(l.height - c.y); for r in 0..ch { diff --git a/crates/dng/src/tiff.rs b/crates/dng/src/tiff.rs index ba479649c..1e8e79c42 100644 --- a/crates/dng/src/tiff.rs +++ b/crates/dng/src/tiff.rs @@ -5,12 +5,16 @@ //! outside the file is an error when it is read, the walk visits at most [`MAX_IFDS`] directories, //! never the same one twice (cyclic chains end), and goes at most [`MAX_DEPTH`] SubIFD levels deep. +use std::collections::HashSet; + use crate::{Error, Result}; /// Most IFDs one file may have (main chain and SubIFDs together). pub const MAX_IFDS: usize = 64; /// Deepest SubIFD nesting followed. pub const MAX_DEPTH: usize = 4; +/// Invalid SubIFD edges count too: otherwise a file can make failed offset probes quadratic. +const MAX_IFD_ATTEMPTS: usize = MAX_IFDS * 16; /// TIFF field types (TIFF 6.0 §2, plus type 13 IFD from TIFF Technical Note 1). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -156,18 +160,22 @@ impl<'a> Tiff<'a> { /// each. Bounded by [`MAX_IFDS`] and [`MAX_DEPTH`]; a directory reached twice ends that branch. pub fn all_ifds(&self, first: u64) -> Result> { let mut out: Vec = Vec::new(); - let mut seen: Vec = Vec::new(); + let mut seen = HashSet::new(); + let mut attempts = 0usize; let mut stack: Vec<(u64, usize)> = vec![(first, 0)]; while let Some((off, depth)) = stack.pop() { let mut next = off; while next != 0 { - if seen.contains(&next) { + if !seen.insert(next) { break; } + attempts += 1; + if attempts > MAX_IFD_ATTEMPTS { + return Err(Error::Malformed("too many IFD offsets")); + } if out.len() >= MAX_IFDS { return Err(Error::Malformed("too many IFDs")); } - seen.push(next); let (ifd, after) = match self.read_ifd(next, depth) { Ok(x) => x, // a broken directory ends the chain; the first one must be readable From 85db84cf93f3bfda797158c96d2e8f71f2382427 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:48:57 +0200 Subject: [PATCH 060/110] security(ogg): bound recovery scanning (#2950) --- crates/ogg/src/lib.rs | 10 ++++++---- crates/ogg/src/tests.rs | 9 +++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/ogg/src/lib.rs b/crates/ogg/src/lib.rs index a251cca3e..2f734b104 100644 --- a/crates/ogg/src/lib.rs +++ b/crates/ogg/src/lib.rs @@ -211,11 +211,12 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { let mut warnings = Vec::new(); let mut pos = 0u64; let mut found_any = false; + let mut recovery_budget = 64 << 20; let mut hdr = [0u8; 27 + 255]; while pos + 27 <= len { src.read_at(pos, &mut hdr[..27])?; if &hdr[..4] != b"OggS" || hdr[4] != 0 { - match find_capture(src, pos + 1, len)? { + match find_capture(src, pos + 1, len, &mut recovery_budget)? { Some(p) => { if found_any { warnings.push(format!("lost sync at byte {pos}; resumed at {p}")); @@ -328,12 +329,13 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { Ok(OggFile { streams, warnings }) } -fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result> { +fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result> { let mut at = from; - while at + 4 <= len { - let n = ((len - at) as usize).min(1 << 20); + while at + 4 <= len && *budget > 0 { + let n = ((len - at) as usize).min(1 << 20).min(*budget); let mut buf = vec![0u8; n]; src.read_at(at, &mut buf)?; + *budget = budget.saturating_sub(n); if let Some(i) = buf.windows(4).position(|w| w == b"OggS") { return Ok(Some(at + i as u64)); } diff --git a/crates/ogg/src/tests.rs b/crates/ogg/src/tests.rs index f1879b36b..b2d7c4c77 100644 --- a/crates/ogg/src/tests.rs +++ b/crates/ogg/src/tests.rs @@ -2,6 +2,15 @@ use super::*; +#[test] +fn recovery_scan_stops_when_its_shared_budget_is_spent() { + let source = b"junkOggS".to_vec(); + let mut budget = 3; + assert_eq!(find_capture(&source, 0, source.len() as u64, &mut budget).unwrap(), None); + let mut budget = source.len(); + assert_eq!(find_capture(&source, 0, source.len() as u64, &mut budget).unwrap(), Some(4)); +} + /// Write one page holding `segments` (lacing already computed by `lace`). fn page(out: &mut Vec, flags: u8, granule: i64, serial: u32, seq: u32, lacing: &[u8], body: &[u8]) { let start = out.len(); From 21df45601c041597f37ed42afb1c2c5ab2c9f665 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:02 +0200 Subject: [PATCH 061/110] security(mxf): bound recovery and PCM edit units (#2948) --- crates/mxf/src/file.rs | 13 ++++++++----- crates/mxf/src/write/mod.rs | 10 ++++++++++ crates/mxf/src/write/tests.rs | 9 +++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/mxf/src/file.rs b/crates/mxf/src/file.rs index 3394b657c..07cead857 100644 --- a/crates/mxf/src/file.rs +++ b/crates/mxf/src/file.rs @@ -327,12 +327,13 @@ fn walk(src: &(impl ByteSource + ?Sized)) -> Result { let mut win = Window { src, off: 0, buf: Vec::new() }; let mut pos = run_in; let mut cur_meta: Option = None; + let mut resync_budget = 64 << 20; while pos + 17 <= len { let hdr = win.get(pos, 25)?.to_vec(); let key = Ul(hdr[..16].try_into().unwrap_or([0; 16])); if !key.is_smpte() { // Lost sync (corrupt data): look for the next partition pack. - match resync(src, pos + 1, len)? { + match resync(src, pos + 1, len, &mut resync_budget)? { Some(p) => { w.warnings.push(format!("lost KLV sync at byte {pos}; resumed at {p}")); pos = p; @@ -418,12 +419,14 @@ fn is_essence_item(k: &Ul) -> bool { matches!(k.0[12], 0x05 | 0x06 | 0x07 | 0x15 | 0x16 | 0x17 | 0x18) } -/// The next partition pack key at or after `from` (scans at most 16 MiB). -fn resync(src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result> { +/// The next partition pack key at or after `from` (scans at most 16 MiB and shares `budget` +/// with every other recovery attempt in this file). +fn resync(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result> { let mut at = from; let end = len.min(from + (16 << 20)); - while at < end { - let chunk = read_upto(src, at, 1 << 20)?; + while at < end && *budget > 0 { + let chunk = read_upto(src, at, (1 << 20).min(*budget))?; + *budget = budget.saturating_sub(chunk.len()); if chunk.len() < 16 { break; } diff --git a/crates/mxf/src/write/mod.rs b/crates/mxf/src/write/mod.rs index 6852e1cd1..939cbd77e 100644 --- a/crates/mxf/src/write/mod.rs +++ b/crates/mxf/src/write/mod.rs @@ -541,6 +541,16 @@ impl MxfWriter { if !cfg.edit_rate.is_valid() { return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF edit rate must be positive")); } + // OP1a pads every sound track to a whole edit unit on flush. Bound that unit before a + // low but technically positive edit rate can turn padding into a multi-gigabyte Vec. + const MAX_PCM_EDIT_UNIT_BYTES: u128 = 64 << 20; + for sound in &cfg.sound { + let samples = (u128::from(sound.sample_rate) * cfg.edit_rate.den as u128).div_ceil(cfg.edit_rate.num as u128); + let bytes = samples.saturating_mul(u128::from(sound.block_align())); + if bytes > MAX_PCM_EDIT_UNIT_BYTES { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF PCM edit unit exceeds the allocation budget")); + } + } let n_essence = cfg.picture.is_some() as usize + cfg.sound.len(); if n_essence == 0 { return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF file without essence tracks")); diff --git a/crates/mxf/src/write/tests.rs b/crates/mxf/src/write/tests.rs index 8728e86c8..5ce7560a8 100644 --- a/crates/mxf/src/write/tests.rs +++ b/crates/mxf/src/write/tests.rs @@ -47,6 +47,15 @@ fn op1a_config(rate: Rational, coding: PictureCoding) -> WriterConfig { cfg } +#[test] +fn op1a_rejects_an_unbounded_pcm_edit_unit_before_padding() { + let mut cfg = WriterConfig::new(Pattern::Op1a, Rational::new(1, 36_000), PackageIds::from_seed("low-rate", "Low Rate")); + cfg.picture = Some(PictureDesc::new(PictureCoding::ProRes { profile: 3 }, 64, 36)); + cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 16 }]; + let err = MxfWriter::new(Cursor::new(Vec::new()), cfg).err().unwrap(); + assert!(err.to_string().contains("PCM edit unit")); +} + /// 10 ProRes-like pictures at 25 fps with stereo 16-bit and mono 24-bit sound pushed in uneven chunks. fn build_op1a() -> Vec { let cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 3 }); From a8d074247a0b8a4068679ad9e2acf5979882d14f Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:07 +0200 Subject: [PATCH 062/110] security(omf): bound metadata expansion and traversal (#2946) --- crates/interchange/src/omf/bento.rs | 27 +++++++++++++++++++++++++++ crates/interchange/src/omf/read.rs | 15 ++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/interchange/src/omf/bento.rs b/crates/interchange/src/omf/bento.rs index d3a0a80d7..2648b81b0 100644 --- a/crates/interchange/src/omf/bento.rs +++ b/crates/interchange/src/omf/bento.rs @@ -72,6 +72,10 @@ enum Seg { At(u64, u32), } +/// Bento values are metadata, not essence payloads. Limit reassembled segments so repeated +/// references cannot amplify a small OMF into an enormous allocation. +const MAX_VALUE_BYTES: usize = 16 << 20; + /// Builds a Bento container. pub(crate) struct Writer { data: Vec, @@ -311,7 +315,18 @@ impl<'a> Container<'a> { /// The bytes of a value. pub fn value(&self, p: &Prop) -> Result, String> { + let total = p.segs.iter().try_fold(0usize, |total, s| { + let len = match s { + Seg::Imm(b) => b.len(), + Seg::At(_, len) => *len as usize, + }; + total.checked_add(len).ok_or_else(|| "Bento value length overflows".to_string()) + })?; + if total > MAX_VALUE_BYTES { + return Err("Bento value exceeds the decoded-value budget".into()); + } let mut out = Vec::new(); + out.try_reserve_exact(total).map_err(|_| "cannot allocate Bento value")?; for s in &p.segs { match s { Seg::Imm(b) => out.extend_from_slice(b), @@ -355,3 +370,15 @@ impl<'a> Container<'a> { key } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repeated_segments_cannot_expand_a_value_past_its_budget() { + let p = Prop { prop: 1, ty: 1, segs: vec![Seg::At(0, MAX_VALUE_BYTES as u32), Seg::Imm(vec![0])] }; + let c = Container { data: &[], objects: BTreeMap::new(), names: HashMap::new(), by_name: HashMap::new() }; + assert!(c.value(&p).unwrap_err().contains("budget")); + } +} diff --git a/crates/interchange/src/omf/read.rs b/crates/interchange/src/omf/read.rs index 3b2d1b369..b46673602 100644 --- a/crates/interchange/src/omf/read.rs +++ b/crates/interchange/src/omf/read.rs @@ -10,6 +10,8 @@ use crate::{Error, Report, Result}; type Uid = [u8; 12]; +const MAX_REFERENCE_VISITS: usize = 65_536; + struct R<'a, 'b> { c: &'b Container<'a>, report: &'b mut Report, @@ -51,8 +53,10 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result { } let cmobs: Vec = mob_list.iter().copied().filter(|&m| class_of(m) == Some(*b"CMOB")).collect(); let mut referenced = HashSet::new(); + let mut visited = HashSet::new(); + let mut visits = 0; for &m in &cmobs { - r.collect_refs(m, &mut referenced, 0); + r.collect_refs(m, &mut referenced, &mut visited, &mut visits, 0); } let primary: Vec = r.refs(head, "OMFI:HEAD:PrimaryMobs").into_iter().filter(|m| cmobs.contains(m)).collect(); let top: Vec = if !primary.is_empty() { @@ -122,10 +126,11 @@ impl R<'_, '_> { self.i32(o, "OMFI:CPNT:Length").unwrap_or(0).max(0) } - fn collect_refs(&self, o: u32, out: &mut HashSet, depth: usize) { - if depth > 64 { + fn collect_refs(&self, o: u32, out: &mut HashSet, visited: &mut HashSet, visits: &mut usize, depth: usize) { + if depth > 64 || !visited.insert(o) || *visits >= MAX_REFERENCE_VISITS { return; } + *visits += 1; if self.class(o) == *b"SCLP" && let Some(id) = self.uid(o, "OMFI:SCLP:SourceID") { @@ -133,12 +138,12 @@ impl R<'_, '_> { } for p in ["OMFI:MOBJ:Slots", "OMFI:SEQU:Components", "OMFI:EFFE:EffectSlots"] { for c in self.refs(o, p) { - self.collect_refs(c, out, depth + 1); + self.collect_refs(c, out, visited, visits, depth + 1); } } for p in ["OMFI:MSLT:Segment", "OMFI:ESLT:ArgValue"] { if let Some(c) = self.r#ref(o, p) { - self.collect_refs(c, out, depth + 1); + self.collect_refs(c, out, visited, visits, depth + 1); } } } From 90327492a633739f489589ff2db95caca605b7b8 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:13 +0200 Subject: [PATCH 063/110] security(export): bound image overlay decoding (#2944) --- crates/export/src/pipeline.rs | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index c47e5f7b8..814b85c4d 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -3,6 +3,7 @@ //! apply the Export effects (image, name and timecode overlays, video limiter) and convert to the //! encoder's input (straight sRGB RGBA8, or encoded R'G'B' floats for HDR). +use std::io::Cursor; use std::sync::Arc; use filmcraft_geom::Vec2; @@ -14,6 +15,29 @@ use rayon::prelude::*; use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay}; use crate::{ExportError, ExportSettings, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame}; +/// Image overlays originate outside the project and are expanded to f32 working images. This +/// admits ordinary 4K artwork while keeping both source decode and resampling bounded. +const MAX_OVERLAY_PIXELS: usize = 16 << 20; + +fn bounded_overlay_dimensions(w: usize, h: usize) -> Result<()> { + if w == 0 || h == 0 || w.checked_mul(h).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) { + return Err(ExportError::Unsupported("image overlay exceeds the pixel budget".into())); + } + Ok(()) +} + +#[cfg(test)] +mod overlay_budget_tests { + use super::*; + + #[test] + fn overlay_pixel_budget_is_checked_without_overflow() { + assert!(bounded_overlay_dimensions(4096, 4096).is_ok()); + assert!(bounded_overlay_dimensions(4097, 4096).is_err()); + assert!(bounded_overlay_dimensions(usize::MAX, 2).is_err()); + } +} + /// Where the rendered picture lands in the output frame. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct Geometry { @@ -392,8 +416,16 @@ fn composite(dst: &mut Image, src: &Image, x: i64, y: i64, opacity: f32) { /// Load a PNG / JPEG overlay as premultiplied linear light, `width` pixels wide. fn load_overlay(path: &str, width: f32) -> Result { let bytes = std::fs::read(path).map_err(|e| ExportError::Io(format!("image overlay {path}: {e}")))?; - let rgba = image::load_from_memory(&bytes).map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?.to_rgba8(); + let mut reader = image::ImageReader::new(Cursor::new(bytes)); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16_384); + limits.max_image_height = Some(16_384); + limits.max_alloc = Some((MAX_OVERLAY_PIXELS * 4) as u64); + reader.limits(limits); + let reader = reader.with_guessed_format().map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?; + let rgba = reader.decode().map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?.to_rgba8(); let (iw, ih) = (rgba.width() as usize, rgba.height() as usize); + bounded_overlay_dimensions(iw, ih)?; let mut img = Image::new(iw, ih); for (o, p) in img.px.as_chunks_mut::<4>().0.iter_mut().zip(rgba.as_raw().as_chunks::<4>().0) { let a = p[3] as f32 / 255.0; @@ -402,8 +434,12 @@ fn load_overlay(path: &str, width: f32) -> Result { } o[3] = a; } + if !width.is_finite() || width <= 0.0 { + return Err(ExportError::Unsupported("image overlay width is invalid".into())); + } let nw = width.round().max(1.0) as usize; - let nh = ((ih as f32 * nw as f32 / iw.max(1) as f32).round() as usize).max(1); + let nh = ((ih as f64 * nw as f64 / iw.max(1) as f64).round() as usize).max(1); + bounded_overlay_dimensions(nw, nh)?; Ok(if (nw, nh) == (iw, ih) { img } else { resample(&img, nw, nh) }) } From b4792ecc06b3a042291bd512b55044dac2a584bd Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:19 +0200 Subject: [PATCH 064/110] security(render): bound burn-in text rasters (#2943) --- crates/render/src/graphics.rs | 36 +++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/render/src/graphics.rs b/crates/render/src/graphics.rs index dd98fe744..316c60dad 100644 --- a/crates/render/src/graphics.rs +++ b/crates/render/src/graphics.rs @@ -8,6 +8,17 @@ use rayon::prelude::*; use crate::image::Image; +/// Effect text is metadata-controlled; do not let its measured bounds allocate an unbounded mask. +const MAX_BURN_TEXT_PIXELS: usize = 16 << 20; + +fn bounded_raster_size(w: f32, h: f32) -> Option<(usize, usize)> { + if !w.is_finite() || !h.is_finite() || w <= 0.0 || h <= 0.0 { + return None; + } + let (w, h) = (w.ceil() as usize, h.ceil() as usize); + (w.checked_mul(h)? <= MAX_BURN_TEXT_PIXELS).then_some((w, h)) +} + /// sRGB-encoded straight colour (0..1) → premultiplied linear. pub fn premul_linear(c: [f32; 4]) -> [f32; 4] { let a = c[3].clamp(0.0, 1.0); @@ -59,7 +70,7 @@ pub fn paint_mask(img: &mut Image, m: &Mask, x0: i32, y0: i32, color: [f32; 4], /// Draw `text` centred on `pos` (layer pixels) at `px` pixels in white over a black box of /// `box_alpha` (the Timecode and Clip Name effects). pub fn burn_text(img: &mut Image, text: &str, family: &str, pos: Vec2, px: f32, box_alpha: f32) { - if text.is_empty() || pos.x.is_nan() { + if text.is_empty() || !pos.x.is_finite() || !pos.y.is_finite() || !px.is_finite() || px <= 0.0 { return; } let st = TextStyle { family: family.into(), style: "Regular".into(), size: px, ..Default::default() }; @@ -72,10 +83,27 @@ pub fn burn_text(img: &mut Image, text: &str, family: &str, pos: Vec2, px: f32, let boxp = filmcraft_text::Path::rect(bx + b[0] - pad, by - vm.cap_height - pad, bx + b[2] + pad, by + vm.descent * 0.6 + pad); if let Some(bb) = boxp.bounds() { let (x0, y0) = (bb.0.floor() as i32, bb.1.floor() as i32); - let m = - filmcraft_text::raster::fill(&boxp, (bb.2.ceil() as i32 - x0).max(1) as usize, (bb.3.ceil() as i32 - y0).max(1) as usize, -x0 as f32, -y0 as f32); - paint_mask(img, &m, x0, y0, [0.0, 0.0, 0.0, 1.0], box_alpha); + if let Some((w, h)) = bounded_raster_size(bb.2 - x0 as f32, bb.3 - y0 as f32) { + let m = filmcraft_text::raster::fill(&boxp, w, h, -x0 as f32, -y0 as f32); + paint_mask(img, &m, x0, y0, [0.0, 0.0, 0.0, 1.0], box_alpha); + } + } + let bounds = l.bounds; + if bounded_raster_size(bounds[2] - bounds[0], bounds[3] - bounds[1]).is_none() { + return; } let (m, x0, y0) = render::rasterize(&l, &render::at(bx, by.round())); paint_mask(img, &m, x0, y0, [1.0, 1.0, 1.0, 1.0], 1.0); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn burn_in_raster_budget_rejects_huge_and_non_finite_bounds() { + assert_eq!(bounded_raster_size(4096.0, 4096.0), Some((4096, 4096))); + assert_eq!(bounded_raster_size(4097.0, 4096.0), None); + assert_eq!(bounded_raster_size(f32::INFINITY, 1.0), None); + } +} From ca3ee00845021a1f898af8b7ae838e5fde94ed5d Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:25 +0200 Subject: [PATCH 065/110] security(captions): bound burn-in overlays (#2941) --- crates/captions/src/burn.rs | 42 +++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/captions/src/burn.rs b/crates/captions/src/burn.rs index 463e9737b..e29c3a5c3 100644 --- a/crates/captions/src/burn.rs +++ b/crates/captions/src/burn.rs @@ -12,6 +12,12 @@ use filmcraft_time::Tick; use filmcraft_text::{ParagraphStyle, TextStyle, layout, render}; +/// Caption overlays are transient render resources, not source images. Keep one cue from +/// materialising an unbounded mask or RGBA buffer. +const MAX_OVERLAY_PIXELS: usize = 16 << 20; +const MAX_CAPTION_SIZE_1080: f32 = 256.0; +const MAX_OUTLINE_RADIUS: f32 = 64.0; + /// A rendered caption: premultiplied linear-light RGBA f32 pixels at `(x, y)` in the frame. #[derive(Clone, Debug, PartialEq)] pub struct Overlay { @@ -110,7 +116,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> return None; } let scale = h as f32 / 1080.0; - let px = (style.size * scale).max(4.0); + let px = (style.size.clamp(4.0, MAX_CAPTION_SIZE_1080) * scale).clamp(4.0, h as f32); let max_w = w as f32 * 0.9; let ts = caption_style(&style.font, px); let mut lines: Vec = Vec::new(); @@ -124,7 +130,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> } let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(&ts.family, &ts.style).face).metrics(px); let (asc, desc) = (vm.ascent, vm.descent); - let lh = px * style.line_spacing.max(0.8); + let lh = px * style.line_spacing.clamp(0.8, 4.0); let pad_x = (px * 0.3).round(); let block_h = lh * lines.len() as f32; let (line_override, align_override) = vtt_overrides(&c.settings); @@ -148,15 +154,24 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> CaptionAlign::Center => (w as f32 - lw) / 2.0, }) .collect(); - let outline = (style.outline * scale).max(0.0); + let outline = (style.outline * scale).clamp(0.0, MAX_OUTLINE_RADIUS); let grow = outline.ceil() + 1.0; let bx0 = xs.iter().zip(&widths).map(|(x, _)| x - pad_x - grow).fold(f32::MAX, f32::min).floor(); let bx1 = xs.iter().zip(&widths).map(|(x, lw)| x + lw + pad_x + grow).fold(f32::MIN, f32::max).ceil(); let by0 = (top - grow).floor(); let by1 = (top + block_h + grow).ceil(); - let (ox, oy) = (bx0 as i32, by0 as i32); - let ow = (bx1 - bx0).max(1.0) as usize; - let oh = (by1 - by0).max(1.0) as usize; + // Text can be wider than the frame (an unbroken imported WebVTT word, for example). Raster + // only the visible intersection; `draw` receives the original coordinates and clips into it. + let (left, top) = (bx0.clamp(0.0, w as f32), by0.clamp(0.0, h as f32)); + let (right, bottom) = (bx1.clamp(0.0, w as f32), by1.clamp(0.0, h as f32)); + if right <= left || bottom <= top { + return None; + } + let (ox, oy) = (left as i32, top as i32); + let (ow, oh) = ((right - left) as usize, (bottom - top) as usize); + if ow.checked_mul(oh).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) { + return None; + } let mut cover = vec![0.0f32; ow * oh]; let mut bg = vec![0.0f32; ow * oh]; for (i, line) in lines.iter().enumerate() { @@ -319,6 +334,21 @@ mod tests { assert!(canvas.chunks(4).any(|p| p[0] > 0.9)); } + #[test] + fn overlay_is_clipped_to_the_frame_for_an_unbroken_word() { + let o = render_caption(&cap(&"W".repeat(10_000)), &CaptionStyle::default(), 640, 360).unwrap(); + assert!(o.x >= 0 && o.y >= 0); + assert!(o.w <= 640 && o.h <= 360, "{}x{}", o.w, o.h); + assert!(o.w.checked_mul(o.h).unwrap() <= MAX_OVERLAY_PIXELS); + } + + #[test] + fn hostile_outline_is_bounded() { + let st = CaptionStyle { outline: 1_000_000.0, ..Default::default() }; + let o = render_caption(&cap("Safe"), &st, 640, 360).unwrap(); + assert!(o.w <= 640 && o.h <= 360, "{}x{}", o.w, o.h); + } + #[test] fn outline_draws_around_text() { let st = CaptionStyle { background: false, outline: 4.0, outline_color: [255, 0, 0, 255], ..Default::default() }; From f9a2d66db1e5245e4679b0069c6b1b424b594f47 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:31 +0200 Subject: [PATCH 066/110] security(mpeg2v): bound access-unit output (#2935) --- crates/mpeg2v/src/lib.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/mpeg2v/src/lib.rs b/crates/mpeg2v/src/lib.rs index 55c1ab9ad..6448207cd 100644 --- a/crates/mpeg2v/src/lib.rs +++ b/crates/mpeg2v/src/lib.rs @@ -44,6 +44,9 @@ use headers::{EXTENSION, GROUP_START, PICTURE_START, SEQUENCE_END, SEQUENCE_HEAD use mc::Frame; use slice::{PicParams, Target, decode_slice, slice_row}; +/// One access unit must not retain an unbounded number of cropped decoded pictures. +const MAX_OUTPUT_PICTURES_PER_ACCESS_UNIT: usize = 8; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("invalid MPEG video: {0}")] @@ -423,6 +426,7 @@ pub struct Decoder { gray: Option>, pending: Option, out: Vec, + output_overflow: bool, errors: usize, last_error: Option<&'static str>, threads: bool, @@ -447,6 +451,7 @@ impl Decoder { gray: None, pending: None, out: Vec::new(), + output_overflow: false, errors: 0, last_error: None, threads: cfg!(feature = "threads"), @@ -476,6 +481,7 @@ impl Decoder { /// Decode a chunk of the elementary stream holding whole pictures (an access unit) tagged /// with `pts`. Returns the pictures that became displayable, in display order. pub fn decode(&mut self, data: &[u8], pts: i64) -> Result> { + self.output_overflow = false; let codes = start_codes(data); let mut err = None; for (k, &(pos, code)) in codes.iter().enumerate() { @@ -554,6 +560,10 @@ impl Decoder { } self.apply_sequence(); self.finish_picture(data, pts); + if self.output_overflow { + self.out.clear(); + return Err(Error::Invalid("access unit produces too many decoded pictures".into())); + } let out = std::mem::take(&mut self.out); match err { Some(e) if out.is_empty() && self.seq.is_none() => Err(e), @@ -571,7 +581,7 @@ impl Decoder { { a.shown = true; let p = to_picture(&a.frame, &a.meta); - self.out.push(p); + self.push_output(p); } std::mem::take(&mut self.out) } @@ -584,6 +594,7 @@ impl Decoder { self.building = None; self.pending = None; self.out.clear(); + self.output_overflow = false; self.closed_gop = false; } @@ -607,6 +618,11 @@ impl Decoder { fn finish_picture(&mut self, data: &[u8], pts: i64) { let Some(pd) = self.pending.take() else { return }; + if pd.slices.is_empty() { + self.errors += 1; + self.last_error = Some("picture has no slices"); + return; + } let Some(seq) = self.seq.clone() else { self.errors += 1; return; @@ -706,13 +722,21 @@ impl Decoder { if let Some(mut prev) = self.bwd.take() { if !prev.shown { prev.shown = true; - self.out.push(to_picture(&prev.frame, &prev.meta)); + self.push_output(to_picture(&prev.frame, &prev.meta)); } self.fwd = Some(prev); } self.bwd = Some(Anchor { frame: Arc::new(frame), meta, shown: false }); } else { - self.out.push(to_picture(&frame, &meta)); + self.push_output(to_picture(&frame, &meta)); + } + } + + fn push_output(&mut self, picture: Picture) { + if self.out.len() >= MAX_OUTPUT_PICTURES_PER_ACCESS_UNIT { + self.output_overflow = true; + } else { + self.out.push(picture); } } } From 355a2b5538d4044ae0370646b58fe1dbe55c2a6f Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:36 +0200 Subject: [PATCH 067/110] security(aaf): bound materialized streams (#2934) --- crates/interchange/src/aaf/store.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/interchange/src/aaf/store.rs b/crates/interchange/src/aaf/store.rs index 02234c463..e9ef2e0e1 100644 --- a/crates/interchange/src/aaf/store.rs +++ b/crates/interchange/src/aaf/store.rs @@ -359,10 +359,12 @@ struct R<'a> { cf: CompoundFile<'a>, paths: Vec>, objects: usize, + materialized_stream_bytes: usize, } const MAX_DEPTH: usize = 200; const MAX_OBJECTS: usize = 2_000_000; +const MAX_MATERIALIZED_STREAM_BYTES: usize = 256 << 20; /// Parse the object tree of an AAF compound file (the root object). pub(crate) fn read(bytes: &[u8]) -> Result { @@ -385,7 +387,7 @@ pub(crate) fn read(bytes: &[u8]) -> Result { } } } - let mut r = R { cf, paths, objects: 0 }; + let mut r = R { cf, paths, objects: 0, materialized_stream_bytes: 0 }; r.obj(0, 0) } @@ -401,9 +403,15 @@ impl R<'_> { self.cf.child(parent, name).ok_or_else(|| bad(format!("missing storage or stream \"{name}\""))) } - fn stream(&self, parent: usize, name: &str) -> Result, String> { + fn stream(&mut self, parent: usize, name: &str) -> Result, String> { let id = self.child(parent, name)?; - self.cf.read(id).map_err(|e| e.to_string()) + let data = self.cf.read(id).map_err(|e| e.to_string())?; + self.materialized_stream_bytes = self + .materialized_stream_bytes + .checked_add(data.len()) + .filter(|&n| n <= MAX_MATERIALIZED_STREAM_BYTES) + .ok_or_else(|| bad("AAF materialized stream data exceeds the memory limit"))?; + Ok(data) } fn obj(&mut self, entry: usize, depth: usize) -> Result { @@ -435,6 +443,9 @@ impl R<'_> { let (Some(pid), Some(sf), Some(len)) = (rd_u16(&props, 4 + i * 6), rd_u16(&props, 6 + i * 6), rd_u16(&props, 8 + i * 6)) else { return Err(bad("truncated property index")); }; + if o.props.iter().any(|(previous, _)| *previous == pid) { + return Err(bad("duplicate property id")); + } let v = props.get(at..at + len as usize).ok_or_else(|| bad("truncated property value"))?; at += len as usize; let value = match sf { From ea8380a24f159aa18bb2789a47cb339bcbedbf15 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:42 +0200 Subject: [PATCH 068/110] security(avi): bound framed audio scans (#2932) --- crates/codecs/src/avi.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/codecs/src/avi.rs b/crates/codecs/src/avi.rs index 659524427..e4622bc42 100644 --- a/crates/codecs/src/avi.rs +++ b/crates/codecs/src/avi.rs @@ -154,6 +154,8 @@ fn random_access(data: &[u8], hevc: bool) -> bool { /// The packets of a framed audio stream: every frame header found in the stream of chunks, and /// the samples each frame decodes to. Bytes that start no frame are skipped (resynchronisation). +const MAX_FRAMED_AUDIO_SCAN_BYTES: u64 = 256 << 20; + fn split_frames( file: &AviFile, bytes: &crate::Src, @@ -161,12 +163,18 @@ fn split_frames( ts: &filmcraft_mpegts::Codec, ) -> (Vec, Vec, Vec, Option) { let Some(st) = file.streams.get(s) else { return Default::default() }; + // AVI indexes can reference the same physical chunk repeatedly. Keep header scanning bounded + // by both the source itself and a practical absolute limit before issuing those repeated reads. + let budget = bytes.0.len().saturating_mul(4).min(MAX_FRAMED_AUDIO_SCAN_BYTES); // chunk start positions in the concatenated stream let mut at = Vec::with_capacity(st.chunks.len()); let mut total = 0usize; for c in &st.chunks { at.push(total); total = total.saturating_add(c.size as usize); + if total as u64 > budget { + return Default::default(); + } } let locate = |pos: usize| -> Option<(usize, usize)> { let k = at.partition_point(|&a| a <= pos).checked_sub(1)?; From 8c928442abfbef72ec5648714e96d532c04de82f Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:48 +0200 Subject: [PATCH 069/110] security(remix): bound analysis resources (#2926) * security(remix): bound analysis resources * fix(audio-dsp): bound public analysis inputs --- crates/audio-dsp/src/remix.rs | 86 +++++++++++++++++++++++++++-------- crates/audio-dsp/src/sync.rs | 13 +++++- crates/engine/src/remix.rs | 6 ++- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/crates/audio-dsp/src/remix.rs b/crates/audio-dsp/src/remix.rs index 78d4ea8b1..52b1de990 100644 --- a/crates/audio-dsp/src/remix.rs +++ b/crates/audio-dsp/src/remix.rs @@ -37,6 +37,13 @@ pub const CONTEXT: usize = 4; pub const MIN_PIECE_BEATS: usize = 4; /// Crossfade at each joint (seconds). pub const XFADE_S: f64 = 0.02; +/// Long enough for a complete music cue while bounding the audio decoded and analysed by Remix. +pub const MAX_ANALYSIS_SECONDS: u64 = 10 * 60; +/// A dense similarity matrix is useful for planning, but must remain a bounded allocation. +const MAX_SIMILARITY_CELLS: usize = 4_000_000; +/// Remix analysis supports ordinary production and high-resolution audio rates, not arbitrary +/// integers supplied through a public library API. +pub const MAX_ANALYSIS_SAMPLE_RATE: u32 = 384_000; /// One piece of the output: `len` samples read from the source at `src`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -139,6 +146,12 @@ impl Analysis { /// Why music cannot be remixed. #[derive(Clone, Debug, PartialEq, Eq)] pub enum RemixError { + /// The source sample rate is zero or above the supported analysis limit. + InvalidSampleRate, + /// The source would exceed the bounded Remix analysis duration. + TooLong, + /// The detected beat grid would need too much similarity storage or work. + TooComplex, /// Not enough rhythmic content to find a beat (silence, noise, speech). NoBeat, /// The source has fewer beats than a remix needs. @@ -150,6 +163,9 @@ pub enum RemixError { impl std::fmt::Display for RemixError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { + RemixError::InvalidSampleRate => "the audio sample rate is not supported for remix analysis", + RemixError::TooLong => "the music is too long to remix", + RemixError::TooComplex => "the music has too many beats to remix", RemixError::NoBeat => "no beat found in the audio", RemixError::TooShort => "the music is too short to remix", RemixError::TargetTooShort => "the target duration is too short for this music", @@ -188,7 +204,8 @@ fn stft_mag(x: &[f32], n: usize, hop: usize) -> Vec> { /// STFT frame for the onset envelope: the power of two nearest to 46 ms (1024 at 22.05 kHz, 2048 /// at 44.1/48 kHz). fn onset_frame(sr: u32) -> usize { - let n = (sr as f64 * 0.046).max(256.0); + // `onset_envelope` is public on its own, so it must remain bounded even outside `analyze`. + let n = (sr.min(MAX_ANALYSIS_SAMPLE_RATE) as f64 * 0.046).max(256.0); 1usize << n.log2().round() as u32 } @@ -344,31 +361,34 @@ fn beat_features(x: &[f32], sr: u32, beats: &[i64]) -> (Vec<[f32; 12]>, Vec<[f32 } frame_cep.push(cep); } - let mut chroma = Vec::with_capacity(beats.len()); - let mut cep = Vec::with_capacity(beats.len()); + // Frames and beats are both ordered. Assign every frame once instead of scanning the entire + // feature timeline for every beat interval. + let mut chroma = vec![[0f32; 12]; beats.len()]; + let mut cep = vec![[0f32; 12]; beats.len()]; + let mut counts = vec![0f32; beats.len()]; let end = x.len() as i64; - for (i, &b) in beats.iter().enumerate() { - let e = beats.get(i + 1).copied().unwrap_or(end); - let (mut c, mut q, mut cnt) = ([0f32; 12], [0f32; 12], 0f32); - for (t, (fc, fq)) in frame_chroma.iter().zip(&frame_cep).enumerate() { - let centre = (t * hop + n / 2) as i64; - if centre >= b && centre < e { - for k in 0..12 { - c[k] += fc[k]; - q[k] += fq[k]; - } - cnt += 1.0; - } + let mut beat = 0; + for (t, (fc, fq)) in frame_chroma.iter().zip(&frame_cep).enumerate() { + let centre = (t * hop + n / 2) as i64; + while beat + 1 < beats.len() && centre >= beats[beat + 1] { + beat += 1; } - if cnt > 0.0 { - q.iter_mut().for_each(|v| *v /= cnt); + if centre >= beats[beat] && centre < end { + for k in 0..12 { + chroma[beat][k] += fc[k]; + cep[beat][k] += fq[k]; + } + counts[beat] += 1.0; + } + } + for ((c, q), count) in chroma.iter_mut().zip(&mut cep).zip(counts) { + if count > 0.0 { + q.iter_mut().for_each(|v| *v /= count); } let norm = c.iter().map(|v| v * v).sum::().sqrt(); if norm > 0.0 { c.iter_mut().for_each(|v| *v /= norm); } - chroma.push(c); - cep.push(q); } // z-score the cepstra across beats let m = cep.len().max(1) as f32; @@ -380,6 +400,10 @@ fn beat_features(x: &[f32], sr: u32, beats: &[i64]) -> (Vec<[f32; 12]>, Vec<[f32 (chroma, cep) } +fn similarity_cells(beats: usize) -> Result { + beats.checked_mul(beats).filter(|&n| n <= MAX_SIMILARITY_CELLS).ok_or(RemixError::TooComplex) +} + fn cos(a: &[f32; 12], b: &[f32; 12]) -> f32 { let d: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); let na = a.iter().map(|v| v * v).sum::().sqrt(); @@ -398,7 +422,13 @@ fn local_max(env: &[f32], t: usize, r: usize) -> f32 { /// Analyse planar audio (channels are summed to mono). pub fn analyze(channels: &[&[f32]], sample_rate: u32) -> Result { + if sample_rate == 0 || sample_rate > MAX_ANALYSIS_SAMPLE_RATE { + return Err(RemixError::InvalidSampleRate); + } let len = channels.iter().map(|c| c.len()).max().unwrap_or(0); + if (len as u64) > u64::from(sample_rate).saturating_mul(MAX_ANALYSIS_SECONDS) { + return Err(RemixError::TooLong); + } let k = 1.0 / channels.len().max(1) as f32; let mono: Vec = (0..len).map(|i| channels.iter().map(|c| c.get(i).copied().unwrap_or(0.0)).sum::() * k).collect(); let (env, n, hop) = onset_envelope(&mono, sample_rate); @@ -434,7 +464,7 @@ pub fn analyze(channels: &[&[f32]], sample_rate: u32) -> Result Vec { /// Low-pass filter (Blackman-windowed sinc, cutoff at 0.42 × the new sample rate) and keep every /// `d`-th sample. pub fn decimate(x: &[f32], d: usize) -> Vec { - if d <= 1 { + if x.is_empty() || d <= 1 { return x.to_vec(); } + // Factors above the input length cannot produce another output sample. Cap the useful factor + // as well as the filter width so a public caller cannot turn a tiny input into a huge plan. + const MAX_FACTOR: usize = 4096; + let d = d.min(x.len()).min(MAX_FACTOR); let half = 8 * d; let fc = 0.42 / d as f64; // cycles per input sample let taps: Vec = { @@ -396,6 +400,13 @@ mod tests { assert!(rh < 0.01, "{rh}"); } + #[test] + fn decimate_does_not_plan_a_filter_for_empty_or_extreme_requests() { + assert!(decimate(&[], usize::MAX).is_empty()); + let tiny = [1.0, -1.0]; + assert_eq!(decimate(&tiny, usize::MAX), decimate(&tiny, tiny.len())); + } + #[test] fn pure_delay_exact_both_signs() { let sr = 48_000; diff --git a/crates/engine/src/remix.rs b/crates/engine/src/remix.rs index 6f46e75e8..87d2a3c88 100644 --- a/crates/engine/src/remix.rs +++ b/crates/engine/src/remix.rs @@ -102,13 +102,17 @@ fn analysis(s: &Session, item: ItemId, source_in: Tick, dur: Tick) -> Result = buf.channels.iter().map(Vec::as_slice).collect(); let a = Arc::new(dsp::analyze(&refs, sr).map_err(|e| EngineError::Other(format!("cannot remix: {e}")))?); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); From d4595df9c442cb51948d5b90b6dad39214ae6628 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:53 +0200 Subject: [PATCH 070/110] security(tts): verify Kokoro checkpoint before parsing (#2918) * security(av1): require unique complete tile coverage * security(tts): verify Kokoro checkpoint before parsing --- Cargo.lock | 1 + crates/tts/Cargo.toml | 3 ++- crates/tts/src/kokoro/weights.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 9ff01577b..96f4338d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2324,6 +2324,7 @@ dependencies = [ "filmcraft-tts-text", "serde", "serde_json", + "sha2", "thiserror 2.0.21", "zip", ] diff --git a/crates/tts/Cargo.toml b/crates/tts/Cargo.toml index 5100d70db..fffdb622d 100644 --- a/crates/tts/Cargo.toml +++ b/crates/tts/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [features] default = [] # Kokoro-82M neural voices on the CPU via candle (pure Rust). Off by default and in wasm builds. -kokoro = ["dep:candle-core", "dep:candle-nn", "dep:zip", "dep:serde_json", "dep:filmcraft-tts-text", "dep:filmcraft-audio-dsp"] +kokoro = ["dep:candle-core", "dep:candle-nn", "dep:zip", "dep:serde_json", "dep:sha2", "dep:filmcraft-tts-text", "dep:filmcraft-audio-dsp"] [dependencies] filmcraft-tts-text = { workspace = true, optional = true } @@ -23,6 +23,7 @@ candle-core = { version = "0.9", default-features = false, optional = true } candle-nn = { version = "0.9", default-features = false, optional = true } zip = { version = "7", default-features = false, optional = true } serde_json = { workspace = true, optional = true } +sha2 = { version = "0.10", optional = true } [[example]] name = "kokoro_say" diff --git a/crates/tts/src/kokoro/weights.rs b/crates/tts/src/kokoro/weights.rs index b65e8de49..12c759c6a 100644 --- a/crates/tts/src/kokoro/weights.rs +++ b/crates/tts/src/kokoro/weights.rs @@ -12,6 +12,7 @@ use std::io::Read; use std::path::Path; use candle_core::{DType, Device, Tensor}; +use sha2::{Digest, Sha256}; use crate::TtsError; @@ -83,6 +84,7 @@ const PARTS: [&str; 5] = ["bert", "bert_encoder", "predictor", "text_encoder", " impl Weights { pub(crate) fn load(path: &Path) -> Result { + verify_checkpoint(path)?; let mut raw: HashMap = HashMap::new(); for part in PARTS { let tensors = candle_core::pickle::read_all_with_key(path, Some(part)).map_err(|e| err(format!("{}: {e}", path.display())))?; @@ -126,6 +128,34 @@ impl Weights { } } +/// Candle's pickle reader is used only for the fixed, catalogued checkpoint. +/// Verify its immutable size and digest before any pickle object is expanded. +fn verify_checkpoint(path: &Path) -> Result<(), TtsError> { + let file = crate::catalog::FILES + .iter() + .find(|file| file.name == "kokoro-v1_0.pth") + .ok_or_else(|| err("Kokoro checkpoint is missing from the package catalogue"))?; + let metadata = std::fs::metadata(path).map_err(|e| err(format!("{}: {e}", path.display())))?; + if metadata.len() != file.size { + return Err(err(format!("{}: checkpoint size does not match the official package", path.display()))); + } + let mut reader = std::fs::File::open(path).map_err(|e| err(format!("{}: {e}", path.display())))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = reader.read(&mut buf).map_err(|e| err(format!("{}: {e}", path.display())))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + let actual = format!("{:x}", hasher.finalize()); + if actual != file.sha256 { + return Err(err(format!("{}: checkpoint digest does not match the official package", path.display()))); + } + Ok(()) +} + /// Number of style vectors in a voice pack (one per input length 1..=510). pub(crate) const PACK_LEN: usize = 510; pub(crate) const STYLE: usize = 256; From 4328e49a49c559a37bdb3bbdd50fe9e93876413b Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:49:59 +0200 Subject: [PATCH 071/110] security(h264): bound hostile picture allocation (#2915) * security(h264): bound reconstructed picture area * security(h264): bound inferred gap pictures --- crates/h264/src/decoder.rs | 8 ++++++++ crates/h264/src/params.rs | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/crates/h264/src/decoder.rs b/crates/h264/src/decoder.rs index 59070d359..93c96f94d 100644 --- a/crates/h264/src/decoder.rs +++ b/crates/h264/src/decoder.rs @@ -18,6 +18,10 @@ use filmcraft_bitstream::{BitReader, annexb_nals, length_prefixed_nals, unescape use std::collections::VecDeque; use std::sync::{Arc, Mutex}; +/// The decoder supports at most 16 reference pictures, so constructing more +/// inferred gap pictures cannot improve a usable reference list. +const MAX_INFERRED_GAP_FRAMES: u32 = 16; + /// One slice of a picture job. struct SliceJob { sh: SliceHeader, @@ -425,6 +429,10 @@ impl Decoder { } else { let max = sps.max_frame_num(); if sh.frame_num != self.prev_ref_frame_num && sh.frame_num != (self.prev_ref_frame_num + 1) % max { + let gap = (sh.frame_num + max - self.prev_ref_frame_num - 1) % max; + if gap > MAX_INFERRED_GAP_FRAMES { + return Err(Error::Invalid("frame number gap exceeds the supported reference limit".into())); + } // frame_num gap (8.2.5.2): insert "non-existing" frames. Their samples are never used by // conforming streams; copy the latest decoded frame for concealment. let meta = Arc::new(output_meta(sps, pts, false)); diff --git a/crates/h264/src/params.rs b/crates/h264/src/params.rs index 5b92a6055..236b7b928 100644 --- a/crates/h264/src/params.rs +++ b/crates/h264/src/params.rs @@ -4,6 +4,10 @@ use crate::error::{Result, ensure, unsupported}; use crate::tables::*; use filmcraft_bitstream::BitReader; +/// Maximum supported reconstructed luma area. 8K UHD fits comfortably while +/// hostile SPS dimensions cannot allocate full pictures and decoder work state. +const MAX_LUMA_SAMPLES: u64 = 1 << 25; + /// Scaling matrices after applying fall-back rules, stored in raster order (weightScale). #[derive(Clone, Debug, PartialEq, Eq)] pub struct ScalingMatrices { @@ -516,6 +520,9 @@ impl Sps { } pub fn check_supported(&self) -> Result<()> { + if u64::from(self.width()).saturating_mul(u64::from(self.height())) > MAX_LUMA_SAMPLES { + return unsupported("picture dimensions exceed the decoder memory limit"); + } if !self.frame_mbs_only { return unsupported("interlaced (field / MBAFF) coding"); } From a70cac4c234a5d0115ca2a2d801925ccb6bccbfc Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:04 +0200 Subject: [PATCH 072/110] security(render): bound embedded LUT resolution (#2901) --- crates/engine/src/color.rs | 3 ++ crates/format/src/lib.rs | 25 ++++++++++++ crates/project/src/lib.rs | 3 ++ crates/render/src/luts.rs | 79 ++++++++++++++++++++++++++++++-------- 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/color.rs b/crates/engine/src/color.rs index 71261bca5..d25270ad5 100644 --- a/crates/engine/src/color.rs +++ b/crates/engine/src/color.rs @@ -85,6 +85,9 @@ pub(crate) fn commands() -> Vec { fn import_lut(s: &mut Session, path: &str, name: Option<&str>) -> Result<(String, Value)> { let text = std::fs::read_to_string(path).map_err(|e| bad("lut.import", format!("{path}: {e}")))?; + if text.len() > filmcraft_project::MAX_LUT_TEXT_BYTES { + return Err(bad("lut.import", format!("{path}: LUT source exceeds the {} byte limit", filmcraft_project::MAX_LUT_TEXT_BYTES))); + } let fmt = LutFormat::from_path(path).ok_or_else(|| bad("lut.import", "expected a .cube or .3dl file"))?; let lut = Lut::parse(&text, Some(fmt)).map_err(|e| bad("lut.import", format!("{path}: {e}")))?; let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "LUT".into()); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9ecd10aab..6ee6e8043 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -216,6 +216,17 @@ pub fn decode(bytes: &[u8]) -> Result { /// Refuse only values that would overflow or exhaust memory later (`Sequence::check_bounds`); /// structural rules such as overlaps are left to the editor, so projects that open today still open. fn validate_loaded(mut loaded: Loaded) -> Result { + // Embedded LUT text is rendered on the hot path. Drop oversize entries at load time so a + // hostile project cannot retain an unrenderable multi-megabyte source or repeatedly offer it + // to renderers. Clips that refer to one simply have no LUT, like a deleted library entry. + let max_lut = filmcraft_project::MAX_LUT_TEXT_BYTES; + loaded.project.luts.retain(|lut| { + let keep = lut.text.len() <= max_lut; + if !keep { + log::warn!("LUT `{}`: {} bytes, dropping entry above {max_lut} byte limit", lut.name, lut.text.len()); + } + keep + }); // Out-of-range audio stream data is repaired, not refused: failing the load would lose the // whole project over one bad index. A media item keeps its first `MAX_AUDIO_STREAMS` streams; a // clip pointing past that plays stream 0. @@ -434,6 +445,20 @@ mod tests { } } + #[test] + fn oversized_embedded_luts_are_dropped_on_load() { + let mut p = Project::new("LUT limit"); + p.luts.push(filmcraft_project::ProjectLut { + id: "huge".into(), + name: "huge".into(), + source_path: None, + format: "cube".into(), + text: "#".repeat(filmcraft_project::MAX_LUT_TEXT_BYTES + 1).into(), + }); + let loaded = decode(&encode(&p, false)).unwrap(); + assert!(loaded.project.luts.is_empty()); + } + /// What is open is stored beside the project. A file without it, or with one that cannot be /// read, opens all the same. #[test] diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 5b21cdaec..b37d82687 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1324,6 +1324,9 @@ impl ProjectView { } /// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:`. +/// Largest embedded LUT source accepted from a project or import (8 MiB). +pub const MAX_LUT_TEXT_BYTES: usize = 8 << 20; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ProjectLut { pub id: String, diff --git a/crates/render/src/luts.rs b/crates/render/src/luts.rs index aa2c29632..c7afe3cda 100644 --- a/crates/render/src/luts.rs +++ b/crates/render/src/luts.rs @@ -10,10 +10,11 @@ //! Parsed LUTs are cached by content, so a library LUT is parsed once however many clips use it. use std::collections::HashMap; +use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex, OnceLock}; use filmcraft_color::{ColorSpace, Lut, Lut3d, LutFormat, Range}; -use filmcraft_project::Project; +use filmcraft_project::{MAX_LUT_TEXT_BYTES, Project}; /// A built-in LUT: id, label, and how it is made. pub struct Builtin { @@ -80,12 +81,38 @@ impl Builtin { } } -fn cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); +/// Identity of immutable project LUT text. Pointer equality is intentional: an `Arc` changes +/// identity whenever a project edit replaces its text, while clones of an unchanged project share +/// it. Keeping the Arc in the cache also prevents allocator-address reuse from aliasing old LUTs. +#[derive(Clone, Debug)] +struct LutText(Arc); + +impl PartialEq for LutText { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for LutText {} + +impl Hash for LutText { + fn hash(&self, state: &mut H) { + (Arc::as_ptr(&self.0) as *const ()).hash(state); + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum CacheKey { + Builtin(String), + Library { text: LutText, format: String }, +} + +fn cache() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); C.get_or_init(Default::default) } -fn cached(key: String, make: impl FnOnce() -> Option) -> Option> { +fn cached(key: CacheKey, make: impl FnOnce() -> Option) -> Option> { if let Some(l) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return Some(l.clone()); } @@ -103,25 +130,18 @@ pub fn resolve(project: Option<&Project>, spec: &str) -> Option> { let spec = spec.trim(); if let Some(id) = spec.strip_prefix("builtin:") { let b = builtins().iter().find(|b| b.id == id)?; - return cached(format!("builtin:{id}"), || Some(b.generate())); + return cached(CacheKey::Builtin(id.to_string()), || Some(b.generate())); } let id = spec.strip_prefix("lib:")?; let entry = project?.luts.iter().find(|l| l.id == id)?; - let key = format!("lib:{:016x}", hash(&entry.text)); + if entry.text.len() > MAX_LUT_TEXT_BYTES { + return None; + } + let key = CacheKey::Library { text: LutText(entry.text.clone()), format: entry.format.clone() }; let fmt = LutFormat::from_path(&format!("x.{}", entry.format)); cached(key, || Lut::parse(&entry.text, fmt).ok()) } -fn hash(s: &str) -> u64 { - // FNV-1a - let mut h = 0xcbf2_9ce4_8422_2325u64; - for b in s.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100_0000_01b3); - } - h ^ s.len() as u64 -} - /// Human-readable name of a LUT reference (for panels). pub fn label(project: Option<&Project>, spec: &str) -> String { if spec.is_empty() { @@ -169,4 +189,31 @@ mod tests { assert_eq!(label(Some(&p), "lib:lut1"), "Swap"); assert_eq!(label(Some(&p), "lib:x"), "x (missing)"); } + + #[test] + fn library_cache_uses_retained_text_identity_without_rehashing_source() { + let text: Arc = Lut::from_cube(Lut3d::from_fn(3, |c| c)).to_cube().into(); + let a = CacheKey::Library { text: LutText(text.clone()), format: "cube".into() }; + let b = CacheKey::Library { text: LutText(text), format: "cube".into() }; + let c = CacheKey::Library { text: LutText(Arc::::from(a_match_source_but_not_identity())), format: "cube".into() }; + assert_eq!(a, b, "unchanged project snapshots share their LUT identity"); + assert_ne!(a, c, "replacement text gets a new cache identity even when bytes match"); + } + + fn a_match_source_but_not_identity() -> String { + Lut::from_cube(Lut3d::from_fn(3, |c| c)).to_cube() + } + + #[test] + fn oversized_embedded_lut_is_not_parsed() { + let mut p = Project::new("t"); + p.luts.push(filmcraft_project::ProjectLut { + id: "too-big".into(), + name: "too-big".into(), + source_path: None, + format: "cube".into(), + text: "#".repeat(MAX_LUT_TEXT_BYTES + 1).into(), + }); + assert!(resolve(Some(&p), "lib:too-big").is_none()); + } } From 894a9b56b61d008ed9ffb0c7436dbc9f6195b896 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:09 +0200 Subject: [PATCH 073/110] security(codecs): cap GOP cache frame retention (#2899) --- crates/codecs/src/gop.rs | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index bcfd4f7ec..dcd19a285 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -446,11 +446,13 @@ pub struct GopCache { budget: usize, } -/// The cache always has room for this many frames, whatever their size: a frame-threaded decoder -/// runs up to ~2x its thread count ahead of the frame it returns, and playback prefetches ahead -/// of the playhead, so a byte budget alone would evict 4K frames before they are shown and force -/// a re-decode from the keyframe. +/// The cache reserves room for this many small frames: a frame-threaded decoder runs up to ~2x +/// its thread count ahead of the frame it returns, and playback prefetches ahead of the playhead. const MIN_FRAMES: usize = 64; +/// A source cache never retains more decoded pixels than this, regardless of frame dimensions. +/// In particular, untrusted VP9 `show_existing_frame` samples must not turn the small-frame +/// reserve above into an unbounded number of full-resolution copies. +const FRAME_CACHE_CEILING: usize = 384 << 20; /// A running decoder up to this many samples before the wanted sample's sync sample keeps going /// rather than restarting at the sync sample. @@ -533,7 +535,7 @@ impl GopCache { pool: pool.clone(), }); pool.register(&shared); - Self { shared, explicit_color, rotation: 0, crop: None, budget: 384 << 20 } + Self { shared, explicit_color, rotation: 0, crop: None, budget: FRAME_CACHE_CEILING } } /// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display @@ -568,6 +570,12 @@ impl GopCache { self.store_finished(st, pts, self.finish(f), draft); } + /// The small-frame reserve may use the configured cache budget, but may never enlarge it. + fn cache_budget(&self, frame_bytes: usize) -> usize { + let reserve = MIN_FRAMES.saturating_mul(frame_bytes).min(FRAME_CACHE_CEILING); + self.budget.min(FRAME_CACHE_CEILING).max(reserve) + } + /// Cache a frame [`Self::finish`] has been applied to. fn store_finished(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { st.last_used = Instant::now(); @@ -577,7 +585,7 @@ impl GopCache { } else { st.drafts.remove(&pts); } - let budget = self.budget.max(MIN_FRAMES * f.byte_size()); + let budget = self.cache_budget(f.byte_size()); let before = st.bytes; st.bytes += f.byte_size(); if let Some(old) = st.frames.insert(pts, Arc::new(f)) { @@ -1449,6 +1457,17 @@ mod tests { } } + #[test] + fn large_frames_cannot_enlarge_the_gop_cache_ceiling() { + let mut c = GopCache::new(None); + c.budget = usize::MAX; + assert_eq!(c.cache_budget(1), FRAME_CACHE_CEILING); + assert_eq!(c.cache_budget(FRAME_CACHE_CEILING), FRAME_CACHE_CEILING); + c.budget = 0; + assert_eq!(c.cache_budget(4), MIN_FRAMES * 4, "small frames retain the playback reserve"); + assert_eq!(c.cache_budget(FRAME_CACHE_CEILING), FRAME_CACHE_CEILING, "large frames are capped instead of reserving 64 copies"); + } + #[test] fn evicted_frames_are_decoded_into_again() { let s = Big(200); From b9689dd3a36a75e8dc22aa65000ab425ebb7b666 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:14 +0200 Subject: [PATCH 074/110] security(mpegts): avoid repeated audio buffer drains (#2898) --- crates/mpegts/src/es.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/mpegts/src/es.rs b/crates/mpegts/src/es.rs index 9735d456e..3219b4d60 100644 --- a/crates/mpegts/src/es.rs +++ b/crates/mpegts/src/es.rs @@ -652,11 +652,14 @@ impl Splitter { match next { Some(d) => { self.skipped += d as u64; - self.trim(self.buf.len() - (i + 1 + d)); + // Keep a cursor into the current window rather than draining its + // prefix for every false sync candidate. + self.next_frame = Some(at + 1 + d as u64); } None => { self.skipped += (self.buf.len() - i - 1) as u64; - self.trim(0); + // A split header can begin in the final few bytes; compact once. + self.trim(7); return; } } From 7095a7f8b9c8f42ebd7218360823e0f3b449598e Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:20 +0200 Subject: [PATCH 075/110] security(captions): bound TTML speaker labels (#2897) --- crates/captions/src/ttml.rs | 38 +++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index 93de581ea..aec0e4c43 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -338,8 +338,42 @@ fn speaker_names(root: roxmltree::Node) -> HashMap { fn speaker(n: roxmltree::Node, names: &HashMap) -> Option { let refs = n.attributes().find(|a| a.name() == "agent" && metadata_namespace(a.namespace())).map(|a| a.value()).or_else(|| n.attribute("agent")); refs.map(|refs| { - // The project stores one speaker label; multiple agents become an ordered display label. - refs.split_whitespace().map(|id| names.get(id).cloned().unwrap_or_else(|| id.trim_start_matches('_').replace('_', " "))).collect::>().join(", ") + // The project stores one speaker label. Keep document-defined long names and repeated + // references from multiplying into an unbounded per-cue allocation. + const MAX_REFS: usize = 64; + const MAX_LABEL_BYTES: usize = 4096; + let mut out = String::new(); + for id in refs.split_whitespace().take(MAX_REFS) { + if !out.is_empty() { + if out.len() + 2 > MAX_LABEL_BYTES { + break; + } + out.push_str(", "); + } + let name = names.get(id).map(String::as_str).unwrap_or(id); + if names.contains_key(id) { + let room = MAX_LABEL_BYTES.saturating_sub(out.len()); + let mut end = 0; + for (i, ch) in name.char_indices() { + if i + ch.len_utf8() > room { + break; + } + end = i + ch.len_utf8(); + } + out.push_str(&name[..end]); + } else { + for ch in id.trim_start_matches('_').chars() { + if out.len() + ch.len_utf8() > MAX_LABEL_BYTES { + break; + } + out.push(if ch == '_' { ' ' } else { ch }); + } + } + if out.len() == MAX_LABEL_BYTES { + break; + } + } + out }) } From 087c7d5721f498dea77d47b1c67ff78fb39557a4 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:25 +0200 Subject: [PATCH 076/110] security(captions): bound TTML inline span output (#2896) --- crates/captions/src/ttml.rs | 45 ++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index aec0e4c43..7dea878b0 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -131,7 +131,23 @@ fn time_expr(t: Tick, rate: Option) -> String { /// Caption text (`\n` lines, ``/``/`` tags) → TTML inline content. fn inline(text: &str) -> String { let mut out = String::new(); - let mut open: Vec<&str> = Vec::new(); + // Keep the logical style state separate from what has been written. There are only three + // supported styles, so duplicate or crossing input tags cannot grow an unbounded span stack. + let mut active: Vec<&'static str> = Vec::new(); + let mut emitted: Vec<&'static str> = Vec::new(); + let sync = |out: &mut String, active: &[&'static str], emitted: &mut Vec<&'static str>| { + if *emitted != active { + for _ in emitted.drain(..) { + out.push_str(""); + } + for attr in active { + out.push_str("'); + emitted.push(attr); + } + } + }; let mut rest = text; while !rest.is_empty() { if let Some(after) = rest.strip_prefix('<') @@ -150,26 +166,16 @@ fn inline(text: &str) -> String { }; if let Some(a) = attr { if closing { - if let Some(pos) = open.iter().rposition(|x| *x == a) { - // close spans opened after it, then it, then reopen the inner ones - let inner: Vec<&str> = open.drain(pos..).skip(1).collect(); - for _ in 0..=inner.len() { - out.push_str(""); - } - for x in inner { - out.push_str(&format!("")); - open.push(x); - } - } - } else { - out.push_str(&format!("")); - open.push(a); + active.retain(|style| *style != a); + } else if !active.contains(&a) { + active.push(a); } } rest = &after[end + 1..]; continue; } let Some(ch) = rest.chars().next() else { break }; + sync(&mut out, &active, &mut emitted); if ch == '\n' { out.push_str("
"); } else { @@ -177,7 +183,7 @@ fn inline(text: &str) -> String { } rest = &rest[ch.len_utf8()..]; } - for _ in open { + for _ in emitted { out.push_str("
"); } out @@ -918,4 +924,11 @@ mod tests { let d = write(&doc, Flavor::Dfxp, Some(r), "en"); assert!(d.contains(DFXP_PROFILE) && d.contains("begin=\"00:00:01.001\""), "{d}"); } + + #[test] + fn duplicate_and_crossing_spans_have_bounded_output() { + let text = format!("{}x{}", "".repeat(1_000), "".repeat(1_000)); + let out = inline(&text); + assert_eq!(out, "x"); + } } From ee871ca06c6bfb264ae404aadb4b4b54e819ccc0 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:30 +0200 Subject: [PATCH 077/110] security(h264): enforce long-term DPB limits (#2894) --- crates/h264/src/decoder.rs | 15 ++++++++------- crates/h264/src/dpb.rs | 16 +++++++++++----- crates/platform/src/vaapi/h264.rs | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/h264/src/decoder.rs b/crates/h264/src/decoder.rs index 93c96f94d..135ebfa59 100644 --- a/crates/h264/src/decoder.rs +++ b/crates/h264/src/decoder.rs @@ -238,7 +238,7 @@ impl Decoder { } } // The access unit is complete: hand its picture to a decoding job. - self.submit_pending(); + self.submit_pending()?; if let Some(e) = self.take_error() { result = result.and(Err(e)); } @@ -258,7 +258,7 @@ impl Decoder { /// Output all remaining pictures (end of stream). pub fn flush(&mut self) -> Vec { - self.submit_pending(); + let _ = self.submit_pending(); let mut outs = Vec::new(); self.dpb.flush(&mut outs); self.emit(outs); @@ -308,7 +308,7 @@ impl Decoder { nal_type::SLICE_DPA | nal_type::SLICE_DPB | nal_type::SLICE_DPC => { return unsupported("data partitioning (Extended profile)"); } - nal_type::END_SEQ | nal_type::END_STREAM => self.submit_pending(), + nal_type::END_SEQ | nal_type::END_STREAM => self.submit_pending()?, // SEI, AUD, filler, SPS extension, prefix NAL, subset SPS, auxiliary and extension slices: skipped. _ => {} } @@ -357,7 +357,7 @@ impl Decoder { Some(p) => is_new_picture(&p.first, &sh, &sps) || sh.first_mb_in_slice == 0 || !Arc::ptr_eq(&p.sps, &sps), }; if new_pic { - self.submit_pending(); + self.submit_pending()?; if sh.first_mb_in_slice != 0 && self.active_sps.is_none() { return invalid("stream does not start with the first slice of a picture"); } @@ -472,8 +472,8 @@ impl Decoder { } /// Start decoding the pending picture and do its reference marking / DPB insertion. - fn submit_pending(&mut self) { - let Some(p) = self.pending.take() else { return }; + fn submit_pending(&mut self) -> Result<()> { + let Some(p) = self.pending.take() else { return Ok(()) }; let PendingPic { frame, sps, first, poc, pts, key, slices } = p; let draft = self.draft && first.nal_ref_idc == 0; self.dispatch(frame.clone(), slices, draft); @@ -484,8 +484,9 @@ impl Decoder { let meta = Arc::new(OutputMeta { draft, ..output_meta(&sps, pts, key) }); let mut outs = Vec::new(); let fpoc = frame.poc; - self.dpb.store_picture(&first, frame, fpoc, sps.max_frame_num(), sps.max_num_ref_frames as usize, meta, &mut outs); + self.dpb.store_picture(&first, frame, fpoc, sps.max_frame_num(), sps.max_num_ref_frames as usize, meta, &mut outs)?; self.emit(outs); + Ok(()) } fn dispatch(&mut self, frame: FrameRef, slices: Vec, draft: bool) { diff --git a/crates/h264/src/dpb.rs b/crates/h264/src/dpb.rs index 657aa411a..df0385184 100644 --- a/crates/h264/src/dpb.rs +++ b/crates/h264/src/dpb.rs @@ -260,7 +260,7 @@ impl Dpb { } /// Adaptive memory control (8.2.5.4). Returns true if MMCO 5 was executed. - fn apply_mmcos(&mut self, mmcos: &[Mmco], cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option) -> bool { + fn apply_mmcos(&mut self, mmcos: &[Mmco], cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option) -> Result { let mut had5 = false; let curr_pic_num = cur_frame_num as i32; for m in mmcos { @@ -281,6 +281,7 @@ impl Dpb { } } 3 => { + ensure!(self.max_long_term_frame_idx.is_some_and(|max| m.long_term_frame_idx <= max), "MMCO long-term index exceeds the configured limit"); let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1); for e in &mut self.entries { if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx { @@ -311,6 +312,7 @@ impl Dpb { had5 = true; } 6 => { + ensure!(self.max_long_term_frame_idx.is_some_and(|max| m.long_term_frame_idx <= max), "MMCO long-term index exceeds the configured limit"); for e in &mut self.entries { if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx { e.mark = RefMark::Unused; @@ -322,7 +324,7 @@ impl Dpb { } } self.prune(); - had5 + Ok(had5) } /// Reference marking for the just-decoded picture and insertion into the DPB with output bumping. @@ -337,7 +339,7 @@ impl Dpb { max_num_ref_frames: usize, meta: Arc, out: &mut Vec>, - ) { + ) -> Result<()> { let mut mark = RefMark::Unused; let mut long_idx = 0; let mut had5 = false; @@ -353,7 +355,7 @@ impl Dpb { } else { let mut cur_long = None; if sh.adaptive_ref_pic_marking { - had5 = self.apply_mmcos(&sh.mmcos, sh.frame_num, max_frame_num, &mut cur_long); + had5 = self.apply_mmcos(&sh.mmcos, sh.frame_num, max_frame_num, &mut cur_long)?; } else { self.sliding_window(sh.frame_num, max_frame_num, max_num_ref_frames); } @@ -369,6 +371,9 @@ impl Dpb { } } } + if mark == RefMark::Long { + ensure!(self.num_refs() < max_num_ref_frames.max(1), "long-term reference exceeds max_num_ref_frames"); + } if had5 { // All earlier pictures are output before the current one (C.4.4). self.flush(out); @@ -385,7 +390,7 @@ impl Dpb { let min_poc = self.entries.iter().filter(|e| e.needed_for_output).map(|e| e.poc).min(); if min_poc.is_none_or(|m| poc < m) { out.push(Output { frame, poc, meta }); - return; + return Ok(()); } if !self.bump(out) { break; @@ -405,6 +410,7 @@ impl Dpb { break; } } + Ok(()) } } diff --git a/crates/platform/src/vaapi/h264.rs b/crates/platform/src/vaapi/h264.rs index bc79ee9a4..6e709c4ad 100644 --- a/crates/platform/src/vaapi/h264.rs +++ b/crates/platform/src/vaapi/h264.rs @@ -333,7 +333,7 @@ impl Front { let fpoc = if mmco5 { 0 } else { p.poc.frame() }; let meta = Arc::new(output_meta(&p.sps, p.pts, p.first.idr)); let mut outs = Vec::new(); - self.dpb.store_picture(&p.first, surface, fpoc, p.sps.max_frame_num(), p.sps.max_num_ref_frames as usize, meta, &mut outs); + self.dpb.store_picture(&p.first, surface, fpoc, p.sps.max_frame_num(), p.sps.max_num_ref_frames as usize, meta, &mut outs).map_err(decode_err)?; self.read_out(outs, frames) } } From d858c4682d153258372a3071a76ff885b811e60e Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:36 +0200 Subject: [PATCH 078/110] security(engine): validate imported sequence settings (#2892) --- crates/engine/src/interchange.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs index d32713fb6..e0f3e82e9 100644 --- a/crates/engine/src/interchange.rs +++ b/crates/engine/src/interchange.rs @@ -81,6 +81,11 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu } _ => filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?, }; + for item in fragment.project.items.values() { + if let ItemKind::Sequence(sequence) = &item.kind { + sequence.settings.validate().map_err(|e| EngineError::Other(format!("{path}: invalid imported sequence settings: {e}")))?; + } + } let before: std::collections::HashSet = s.project.items.keys().copied().collect(); let name = opts.name.clone().unwrap_or_else(|| format.name().to_string()); let seqs = s.edit(&format!("Import {name}"), |proj, _| { From 036086fa5b6fe44c6755d82b6e4fc81ca198cae5 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:41 +0200 Subject: [PATCH 079/110] security(speech): bound diarization clustering work (#2891) --- crates/speech/src/diarize.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/speech/src/diarize.rs b/crates/speech/src/diarize.rs index 3271b0257..d09a6b749 100644 --- a/crates/speech/src/diarize.rs +++ b/crates/speech/src/diarize.rs @@ -46,6 +46,9 @@ impl Default for Params { const N_MELS: usize = 40; const N_MFCC: usize = 20; const FRAME_TICKS: i64 = TICKS_PER_SAMPLE * crate::mel::HOP as i64; +/// Agglomerative linkage is cubic in the number of turns; beyond this, retain a safe single-speaker +/// transcript instead of making a document-controlled amount of work. +const MAX_CLUSTER_CHUNKS: usize = 256; /// Word index ranges of the chunks. pub fn chunks(t: &Transcript, p: &Params) -> Vec> { @@ -125,6 +128,9 @@ pub fn diarize(audio: &[f32], t: &mut Transcript, p: &Params) -> usize { if ch.len() < 2 { return one(t); } + if ch.len() > MAX_CLUSTER_CHUNKS { + return one(t); + } let (frames, energy, mfcc) = mfcc_frames(audio); // chunk vectors: mean and std of MFCC 1..N over voiced frames let dim = 2 * (N_MFCC - 1); @@ -329,4 +335,12 @@ mod tests { let t = Transcript { words: vec![w(0.0, 0.5), w(0.5, 1.0), w(2.0, 2.2), w(3.0, 3.6), w(3.6, 4.4)], ..Default::default() }; assert_eq!(chunks(&t, &Params::default()), vec![0..3, 3..5]); } + + #[test] + fn excessive_turns_fall_back_without_clustering() { + let words = (0..=MAX_CLUSTER_CHUNKS).map(|i| Word::new("x", seconds_tick(i as f64 * 2.0), seconds_tick(i as f64 * 2.0 + 1.0))).collect(); + let mut t = Transcript { words, ..Default::default() }; + assert_eq!(diarize(&[], &mut t, &Params::default()), 1); + assert!(t.words.iter().all(|word| word.speaker == Some(0))); + } } From f30c6be22866b55f1db544f65b5b7580ef8820e8 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:47 +0200 Subject: [PATCH 080/110] security(project): scan malformed caption markup once (#2885) --- crates/project/src/caption.rs | 41 +++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/crates/project/src/caption.rs b/crates/project/src/caption.rs index d8236e8bd..30c279d26 100644 --- a/crates/project/src/caption.rs +++ b/crates/project/src/caption.rs @@ -239,25 +239,36 @@ pub fn plain_text(s: &str) -> String { while let Some(c) = chars.next() { match c { '<' => { - // a tag runs to the next '>' (if none, keep the '<' literally) - let rest: String = chars.clone().collect(); - if let Some(end) = rest.find('>') { - for _ in 0..rest[..=end].chars().count() { - chars.next(); + // A tag runs to the next '>'. Scan forward once; preserving an unterminated + // tag as literal text must not rescan every later opening bracket. + let mut literal = String::new(); + let mut closed = false; + for next in chars.by_ref() { + if next == '>' { + closed = true; + break; } - } else { + literal.push(next); + } + if !closed { out.push('<'); + out.push_str(&literal); } } '{' if chars.peek() == Some(&'\\') => { // SSA-style override blocks some SRT files carry: {\an8} - let rest: String = chars.clone().collect(); - if let Some(end) = rest.find('}') { - for _ in 0..rest[..=end].chars().count() { - chars.next(); + let mut literal = String::new(); + let mut closed = false; + for next in chars.by_ref() { + if next == '}' { + closed = true; + break; } - } else { + literal.push(next); + } + if !closed { out.push('{'); + out.push_str(&literal); } } '&' => { @@ -300,6 +311,14 @@ mod tests { assert_eq!(plain_text("Hi"), "Hi"); } + #[test] + fn malformed_caption_markup_is_preserved_without_repeated_suffix_scans() { + let angle = "<".repeat(10_000); + let ssa = format!("{{\\{}", "{".repeat(10_000)); + assert_eq!(plain_text(&angle), angle); + assert_eq!(plain_text(&ssa), ssa); + } + #[test] fn caption_at() { let mut t = CaptionTrack::new(TrackId(1), "Subtitle".into(), CaptionFormat::Subtitle); From 5f2a77e3c87d3ad69f56780252f32172e3ddad95 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:53 +0200 Subject: [PATCH 081/110] security(hevc): bound specification equation parsing (#2884) --- crates/hevc/tools/extract_tables.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/hevc/tools/extract_tables.py b/crates/hevc/tools/extract_tables.py index 8b7d7d35e..7d6cbc89f 100644 --- a/crates/hevc/tools/extract_tables.py +++ b/crates/hevc/tools/extract_tables.py @@ -187,7 +187,9 @@ w("") def taps(eq_no, var, n): for l in lines: if "(%s)" % eq_no in l: - expr = l.split("=", 1)[1].split(">>")[0] + expr = " ".join(l.split("=", 1)[1].split(">>")[0].split()) + if len(expr) > 8192: + raise SystemExit("equation %s is unexpectedly long" % eq_no) coef = {} for sign, c, k in re.findall(r"([+-]?)\s*(?:(\d+)\s*\*\s*)?%s0,\s*(-?\d)" % var, expr): coef[int(k)] = (-1 if sign == "-" else 1) * (int(c) if c else 1) From 52794db3213732d9c669edb83240715b25625a49 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:50:59 +0200 Subject: [PATCH 082/110] security(format): preserve permissions on atomic replacement (#2881) --- crates/format/src/atomic.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 1e4d06a6a..fc6d29dcd 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -28,11 +28,21 @@ fn parent_dir(path: &Path) -> PathBuf { /// `fsync` the directory so the rename itself survives a power cut. If anything fails the temp file /// is removed and `path` is untouched. pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> { + // `rename` replaces the inode, so a new staging file must inherit an existing project's + // access mode instead of silently broadening a private file to the process default. + let permissions = match fs::metadata(path) { + Ok(meta) => Some(meta.permissions()), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; let tmp = temp_path(path); let r = (|| { { // closed before the rename let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?; + if let Some(permissions) = permissions.as_ref() { + f.set_permissions(permissions.clone())?; + } f.write_all(data)?; f.sync_all()?; } @@ -122,6 +132,21 @@ mod tests { fs::remove_dir_all(&d).unwrap(); } + #[cfg(unix)] + #[test] + fn replacement_preserves_existing_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let d = tmpdir("permissions"); + let p = d.join("private.fcproj"); + fs::write(&p, b"before").unwrap(); + fs::set_permissions(&p, fs::Permissions::from_mode(0o600)).unwrap(); + atomic_write(&p, b"after").unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"after"); + assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); + fs::remove_dir_all(&d).unwrap(); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn new_file_is_published_complete_without_clobbering_or_temp_leaks() { From 2d0382320a158496e709fed7723d4da3c9369d7f Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:51:04 +0200 Subject: [PATCH 083/110] security(captions): bound malformed text processing (#2879) --- crates/captions/src/mcc.rs | 33 +++++++++++++++++++++++++++++---- crates/captions/src/scc.rs | 20 +++++++++++++++++++- crates/captions/src/stl.rs | 30 +++++++++++++++++++++++++++--- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/crates/captions/src/mcc.rs b/crates/captions/src/mcc.rs index 3004872bb..ffc155df9 100644 --- a/crates/captions/src/mcc.rs +++ b/crates/captions/src/mcc.rs @@ -344,6 +344,8 @@ struct Win { defined: bool, visible: bool, rows: Vec, + max_rows: usize, + max_cols: usize, } struct Dec708 { @@ -416,8 +418,9 @@ impl Dec708 { fn put(&mut self, c: char) { let w = self.w(); match w.rows.last_mut() { - Some(row) => row.push(c), - None => w.rows.push(c.to_string()), + Some(row) if row.chars().count() < w.max_cols => row.push(c), + None if w.max_rows > 0 && w.max_cols > 0 => w.rows.push(c.to_string()), + _ => {} } } fn bitmap(&mut self, m: u8, f: impl Fn(&mut Win)) { @@ -441,7 +444,8 @@ impl Dec708 { } } 0x0c => self.w().rows = vec![String::new()], - 0x0d => self.w().rows.push(String::new()), + 0x0d if self.w().rows.len() < self.w().max_rows => self.w().rows.push(String::new()), + 0x0d => {} 0x0e => { if let Some(r) = self.w().rows.last_mut() { r.clear(); @@ -500,11 +504,24 @@ impl Dec708 { 0x98..=0x9f => { let n = (c - 0x98) as usize; let vis = arg(i) & 0x20 != 0; + let max_rows = (arg(i + 3) & 0x0f) as usize + 1; + let max_cols = (arg(i + 4) & 0x3f) as usize + 1; let w = &mut self.win[n]; if !w.defined { - *w = Win { defined: true, visible: vis, rows: vec![String::new()] }; + *w = Win { defined: true, visible: vis, rows: vec![String::new()], max_rows, max_cols }; } else { w.visible = vis; + w.max_rows = max_rows; + w.max_cols = max_cols; + w.rows.truncate(max_rows); + for row in &mut w.rows { + if let Some((at, _)) = row.char_indices().nth(max_cols) { + row.truncate(at); + } + } + if w.rows.is_empty() { + w.rows.push(String::new()); + } } self.cur = n; i += 6; @@ -699,4 +716,12 @@ mod tests { assert_eq!(got, vec![(60, 120, "Hello, world!"), (120, 200, "Second caption\nwith two lines"), (260, 300, "Café “q” ♪")]); assert!(parse("garbage").is_err()); } + + #[test] + fn decoder_keeps_708_text_within_declared_window_dimensions() { + let mut decoder = Dec708::new(); + // Define window 0 with one row and one column, then try to overflow both. + decoder.service_data(&[0x98, 0, 0, 0, 0, 0, 0, b'A', b'B', 0x0d, b'C']); + assert_eq!(decoder.win[0].rows, ["A"]); + } } diff --git a/crates/captions/src/scc.rs b/crates/captions/src/scc.rs index 9fe275ed6..e894e17cf 100644 --- a/crates/captions/src/scc.rs +++ b/crates/captions/src/scc.rs @@ -304,7 +304,12 @@ pub fn wrap_rows(text: &str) -> Vec { for line in text.lines() { let mut cur = String::new(); for word in line.split_whitespace() { - let mut word = word.to_string(); + if rows.len() == 15 { + return rows; + } + // SCC can emit at most 15 32-column rows. Do not repeatedly copy an unbounded word + // only to discard all but that small prefix below. + let mut word: String = word.chars().take(COLS * (15 - rows.len())).collect(); loop { let need = if cur.is_empty() { word.chars().count() } else { cur.chars().count() + 1 + word.chars().count() }; if need <= COLS { @@ -316,11 +321,17 @@ pub fn wrap_rows(text: &str) -> Vec { } if !cur.is_empty() { rows.push(std::mem::take(&mut cur)); + if rows.len() == 15 { + return rows; + } continue; } let head: String = word.chars().take(COLS).collect(); word = word.chars().skip(COLS).collect(); rows.push(head); + if rows.len() == 15 { + return rows; + } if word.is_empty() { break; } @@ -541,6 +552,13 @@ mod tests { assert_eq!(rows.join(" "), "the quick brown fox jumps over the lazy dog and keeps running far away"); } + #[test] + fn wrapping_discards_an_unrepresentable_word_without_quadratic_copying() { + let rows = wrap_rows(&"x".repeat(100_000)); + assert_eq!(rows.len(), 15); + assert!(rows.iter().all(|row| row.len() == COLS)); + } + #[test] fn cue_at_zero_is_delayed_not_lost() { let doc = Document { cues: vec![cue(0, 60, "Start")], ..Default::default() }; diff --git a/crates/captions/src/stl.rs b/crates/captions/src/stl.rs index da49f3b10..5b0e9eeff 100644 --- a/crates/captions/src/stl.rs +++ b/crates/captions/src/stl.rs @@ -212,9 +212,13 @@ fn encode_text(text: &str) -> Vec { let mut out = Vec::new(); let mut rest = text; while let Some(ch) = rest.chars().next() { - if ch == '<' - && let Some(end) = rest.find('>') - { + let is_tag_start = ch == '<' + && rest + .get(1..) + .and_then(|tail| tail.strip_prefix('/').or(Some(tail))) + .and_then(|name| name.as_bytes().first()) + .is_some_and(u8::is_ascii_alphabetic); + if is_tag_start && let Some(end) = rest.find('>') { let tag = &rest[1..end]; // Only a syntactically plausible tag is markup. Comparisons such as // "2 < 3 > 1" and "x < y > z" must not lose their middle text. @@ -232,6 +236,18 @@ fn encode_text(text: &str) -> Vec { continue; } } + if is_tag_start && !rest.contains('>') { + // This cannot become markup later. Encode the remaining literal text in one pass + // instead of searching the shrinking suffix after every opening bracket. + for ch in rest.chars() { + if ch == '\n' { + out.push(0x8a); + } else { + out.extend(encode_6937(ch).unwrap_or_else(|| vec![b'?'])); + } + } + break; + } if ch == '\n' { out.push(0x8a); } else { @@ -474,6 +490,14 @@ mod tests { assert_eq!(encode_text("Hi"), vec![0x80, b'H', b'i', 0x81]); } + #[test] + fn unterminated_markup_is_encoded_in_one_pass() { + let text = format!(" Date: Sat, 24 Oct 2026 10:51:10 +0200 Subject: [PATCH 084/110] security(frame): bound decimation sampling work (#2878) --- crates/frame/src/lib.rs | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/frame/src/lib.rs b/crates/frame/src/lib.rs index 4be8cfb1f..6548ebe2c 100644 --- a/crates/frame/src/lib.rs +++ b/crates/frame/src/lib.rs @@ -139,6 +139,7 @@ impl VideoFrame { /// and bit depth unchanged. Reduced-resolution playback hands the GPU this instead of the full /// picture (a quarter / sixteenth of the upload). None for RGBA frames or `n` < 2. pub fn box_decimated(&self, n: usize) -> Option { + let n = self.bounded_decimation(n); if n < 2 { return None; } @@ -285,7 +286,7 @@ impl VideoFrame { /// `decode` (a colour-managed curve: log, PQ, HLG scene light…) instead of the frame's /// transfer. Float frames are already linear and ignore it. pub fn to_linear_f32_decimated_with(&self, n: usize, decode: Option<&DecodeTable>) -> (usize, usize, Vec) { - let n = n.max(1); + let n = self.bounded_decimation(n); let (ow, oh) = self.decimated_size(n); // every element is written below, so a recycled buffer needs no zero-fill let mut out = pool::take_f32_overwritten(ow * oh * 4); @@ -295,7 +296,7 @@ impl VideoFrame { /// The size of the picture after `n`×`n` box decimation. pub fn decimated_size(&self, n: usize) -> (usize, usize) { - let n = n.max(1); + let n = self.bounded_decimation(n); ((self.width as usize / n).max(1), (self.height as usize / n).max(1)) } @@ -304,7 +305,7 @@ impl VideoFrame { /// by row. Each pixel is exactly what the full conversion gives at that position. The buffer /// may come from the pool ([`pool::recycle_f32`] hands it back). pub fn to_linear_f32_region(&self, n: usize, decode: Option<&DecodeTable>, region: Region) -> (Region, Vec) { - let n = n.max(1); + let n = self.bounded_decimation(n); let (ow, oh) = self.decimated_size(n); let r = region.clip(ow, oh); let mut out = pool::take_f32_overwritten(r.w * r.h * 4); @@ -323,6 +324,7 @@ impl VideoFrame { out.fill(0.0); return; } + let n = n.min(w.max(h)).max(1); if let PixelData::RgbaF32(d) = &self.data && n == 1 { @@ -343,7 +345,7 @@ impl VideoFrame { Some(t) => t.lookup(v), None => lin[(v.clamp(0.0, 1.0) * 4095.0 + 0.5) as usize], }; - let inv = 1.0 / (n * n) as f32; + let inv = 1.0 / (n as f32 * n as f32); match &self.data { PixelData::RgbaF32(d) => { out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| { @@ -477,7 +479,7 @@ impl VideoFrame { /// or its alpha is not stored as integers), so nothing can be skipped. Pixels outside the /// rectangle have alpha exactly 0 and, in the premultiplied conversion, colour 0 as well. pub fn alpha_region(&self, n: usize) -> Option { - let n = n.max(1); + let n = self.bounded_decimation(n); let (w, h) = (self.width as usize, self.height as usize); let px = w.checked_mul(h)?; // the first and last x of non-transparent pixels in source row `y` @@ -502,6 +504,12 @@ impl VideoFrame { Some(Region { x: rx0, y: ry0, w: rx1.saturating_sub(rx0), h: ry1.saturating_sub(ry0) }.clip(ow, oh)) } + /// Keep hostile decimation requests from doing work beyond the source picture. Larger boxes + /// only resample the edge pixels, so clamping them has no useful effect on the reduced output. + fn bounded_decimation(&self, n: usize) -> usize { + n.clamp(1, (self.width as usize).max(self.height as usize).max(1)) + } + /// Convert to straight-alpha sRGB RGBA8 for display (fast paths for 8-bit sources). pub fn to_rgba8(&self) -> Vec { let (w, h) = (self.width as usize, self.height as usize); @@ -830,6 +838,24 @@ mod tests { assert!(f.box_decimated(1).is_none()); assert!(VideoFrame::rgba8(2, 2, vec![0; 16]).box_decimated(2).is_none()); } + + #[test] + fn decimation_caps_hostile_factors_to_the_frame_size() { + let rgba = VideoFrame::rgba8(1, 1, vec![23, 42, 99, 255]); + let (w, h, pixels) = rgba.to_linear_f32_decimated(usize::MAX); + assert_eq!((w, h), (1, 1)); + assert_eq!(pixels, rgba.to_linear_f32()); + + let yuv = VideoFrame { + width: 2, + height: 2, + data: PixelData::Yuv8 { planes: [Arc::new(vec![1, 2, 3, 4]), Arc::new(vec![128]), Arc::new(vec![128])], chroma: Chroma::C420, alpha: None }, + color: ColorInfo::REC709, + par: (1, 1), + pts: Tick::ZERO, + }; + assert_eq!(yuv.box_decimated(usize::MAX).expect("yuv").luma8(), vec![3]); + } use super::*; #[test] From e69122c0b3404f6a776195694b860f4db78abf6b Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:51:16 +0200 Subject: [PATCH 085/110] fix(speech): contain and verify downloaded model artifacts (#2867) --- Cargo.lock | 1 + crates/speech/Cargo.toml | 7 ++-- crates/speech/src/models.rs | 72 +++++++++++++++++++++++++++++++++---- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96f4338d0..9ce535a44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2281,6 +2281,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "thiserror 2.0.21", "ureq", ] diff --git a/crates/speech/Cargo.toml b/crates/speech/Cargo.toml index 67f6d8fd6..d64ea0d88 100644 --- a/crates/speech/Cargo.toml +++ b/crates/speech/Cargo.toml @@ -14,7 +14,7 @@ default = [] # Whisper inference on the CPU via candle (pure Rust). Off by default (and in wasm builds). whisper = ["dep:candle-core", "dep:candle-nn"] # Model downloads over HTTPS (pure-Rust TLS: rustls + RustCrypto, OS certificate verification). -download = ["dep:ureq", "dep:rustls", "dep:rustls-rustcrypto", "dep:sha2"] +download = ["dep:ureq", "dep:rustls", "dep:rustls-rustcrypto"] [dependencies] filmcraft-time = { workspace = true } @@ -28,8 +28,11 @@ rayon = { workspace = true } ureq = { version = "3", default-features = false, features = ["rustls-no-provider", "platform-verifier"], optional = true } rustls = { version = "0.23", default-features = false, optional = true } rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } -sha2 = { version = "0.10", optional = true } +sha2 = "0.10" [[example]] name = "transcribe" required-features = ["whisper"] + +[dev-dependencies] +tempfile = "3.27" diff --git a/crates/speech/src/models.rs b/crates/speech/src/models.rs index 4d257159d..9fcccc5f5 100644 --- a/crates/speech/src/models.rs +++ b/crates/speech/src/models.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use serde::Serialize; +use sha2::Digest; /// One file of a model. #[derive(Clone, Copy, Debug, Serialize)] @@ -195,16 +196,36 @@ pub fn model_dir(models_dir: &Path, m: &ModelInfo) -> PathBuf { models_dir.join(m.id) } -/// All files present with their catalogue sizes (contents were verified when downloaded). +fn valid_name(name: &str) -> bool { + !name.is_empty() + && !name.contains(['/', '\\']) + && !matches!(name, "." | "..") + && Path::new(name).components().count() == 1 + && Path::new(name).file_name().is_some_and(|part| part == name) +} + +fn valid_file(path: &Path, file: &ModelFile) -> bool { + if !valid_name(file.name) || !std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() == file.size) { + return false; + } + let Ok(mut input) = std::fs::File::open(path) else { return false }; + let mut hasher = sha2::Sha256::new(); + if std::io::copy(&mut input, &mut hasher).is_err() { + return false; + } + format!("{:x}", hasher.finalize()).eq_ignore_ascii_case(file.sha256) +} + +/// All files present and matching their pinned size and SHA-256. pub fn installed(models_dir: &Path, m: &ModelInfo) -> bool { let d = model_dir(models_dir, m); - m.files.iter().all(|f| std::fs::metadata(d.join(f.name)).is_ok_and(|md| md.len() == f.size)) + m.files.iter().all(|f| valid_file(&d.join(f.name), f)) } /// Bytes still to download for `m` (files already present are skipped). pub fn missing_bytes(models_dir: &Path, m: &ModelInfo) -> u64 { let d = model_dir(models_dir, m); - m.files.iter().filter(|f| !std::fs::metadata(d.join(f.name)).is_ok_and(|md| md.len() == f.size)).map(|f| f.size).sum() + m.files.iter().filter(|f| !valid_file(&d.join(f.name), f)).map(|f| f.size).sum() } /// The tokenizer shared by the multilingual models has one checksum. @@ -231,15 +252,17 @@ pub fn download(models_dir: &Path, m: &ModelInfo, progress: DownloadProgress) -> #[cfg(feature = "download")] pub fn download_files(dir: &Path, files: &[ModelFile], progress: DownloadProgress) -> Result<(), crate::SpeechError> { use crate::SpeechError; - use sha2::Digest; use std::io::{Read, Write}; + if let Some(file) = files.iter().find(|file| !valid_name(file.name)) { + return Err(SpeechError::Download(format!("invalid model artifact name: {:?}", file.name))); + } std::fs::create_dir_all(dir)?; - let total: u64 = files.iter().filter(|f| !std::fs::metadata(dir.join(f.name)).is_ok_and(|md| md.len() == f.size)).map(|f| f.size).sum(); + let total: u64 = files.iter().filter(|f| !valid_file(&dir.join(f.name), f)).map(|f| f.size).sum(); let agent = agent(); let mut done = 0u64; for f in files { let dest = dir.join(f.name); - if std::fs::metadata(&dest).is_ok_and(|md| md.len() == f.size) { + if valid_file(&dest, f) { continue; } let part = dir.join(format!("{}.part", f.name)); @@ -322,4 +345,41 @@ mod tests { assert_eq!(missing_bytes(&dir, m), m.size()); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn artifact_names_stay_inside_the_model_directory() { + for name in ["../escape", "/tmp/escape", r"..\escape", r"C:\escape", "", ".", ".."] { + assert!(!valid_name(name), "accepted {name:?}"); + } + assert!(valid_name("model.safetensors")); + } + + #[test] + fn same_length_corruption_is_not_installed() { + static FILES: &[ModelFile] = &[ModelFile { + name: "tiny.bin", + url: "https://example.invalid/tiny.bin", + sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + size: 3, + }]; + let model = ModelInfo { + id: "integrity-test", + name: "integrity test", + multilingual: false, + description: "", + license: "", + license_url: "", + author: "", + source: "", + files: FILES, + }; + let root = tempfile::tempdir().unwrap(); + let directory = model_dir(root.path(), &model); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("tiny.bin"), b"bad").unwrap(); + assert!(!installed(root.path(), &model)); + assert_eq!(missing_bytes(root.path(), &model), 3); + std::fs::write(directory.join("tiny.bin"), b"abc").unwrap(); + assert!(installed(root.path(), &model)); + } } From da691ce78ab120149e14e89d9be75a0a8a2db9b2 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sat, 24 Oct 2026 10:51:22 +0200 Subject: [PATCH 086/110] fix(speech): bound Whisper model metadata allocations (#2866) --- crates/speech/src/whisper/mod.rs | 57 +++++++++++++++++++++++--- crates/speech/src/whisper/tokenizer.rs | 24 ++++++++++- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/speech/src/whisper/mod.rs b/crates/speech/src/whisper/mod.rs index 6a602bce1..b84c82e9c 100644 --- a/crates/speech/src/whisper/mod.rs +++ b/crates/speech/src/whisper/mod.rs @@ -43,6 +43,8 @@ const N_FRAMES: usize = 3000; const FRAMES_PER_TS: usize = 2; const SAMPLES_PER_FRAME: usize = crate::mel::HOP; const MAX_TOKENS: usize = 224; +/// More than the total alignment heads in any published Whisper architecture. +const MAX_ALIGNMENT_HEADS: usize = 4_096; fn merr(e: candle_core::Error) -> SpeechError { SpeechError::Model(e.to_string()) @@ -66,11 +68,7 @@ impl Whisper { let gen_cfg: serde_json::Value = serde_json::from_slice(&read("generation_config.json")?).unwrap_or_default(); let tok = Tokenizer::from_json(&String::from_utf8_lossy(&read("tokenizer.json")?))?; let weights = read("model.safetensors")?; - let alignment_heads = gen_cfg["alignment_heads"] - .as_array() - .map(|a| a.iter().filter_map(|p| Some((p[0].as_u64()? as usize, p[1].as_u64()? as usize))).collect::>()) - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| (cfg.decoder_layers / 2..cfg.decoder_layers).flat_map(|l| (0..cfg.decoder_attention_heads).map(move |h| (l, h))).collect()); + let alignment_heads = alignment_heads(&cfg, &gen_cfg)?; let mut suppress: Vec = gen_cfg["suppress_tokens"].as_array().map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as u32)).collect()).unwrap_or_default(); suppress.extend(tok.non_text_specials()); @@ -193,6 +191,34 @@ impl Whisper { } } +fn alignment_heads(cfg: &Config, generation: &serde_json::Value) -> Result, SpeechError> { + let bad = |message: &str| SpeechError::Model(format!("generation_config.json: {message}")); + if let Some(values) = generation["alignment_heads"].as_array().filter(|values| !values.is_empty()) { + if values.len() > MAX_ALIGNMENT_HEADS { + return Err(bad("too many alignment heads")); + } + return values + .iter() + .map(|pair| { + let layer = + pair.get(0).and_then(serde_json::Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("invalid alignment head"))?; + let head = + pair.get(1).and_then(serde_json::Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("invalid alignment head"))?; + if layer >= cfg.decoder_layers || head >= cfg.decoder_attention_heads { + return Err(bad("alignment head is outside the decoder architecture")); + } + Ok((layer, head)) + }) + .collect(); + } + let first_layer = cfg.decoder_layers / 2; + let count = cfg.decoder_layers.saturating_sub(first_layer).checked_mul(cfg.decoder_attention_heads).ok_or_else(|| bad("alignment-head count overflow"))?; + if count > MAX_ALIGNMENT_HEADS { + return Err(bad("alignment-head fallback is too large")); + } + Ok((first_layer..cfg.decoder_layers).flat_map(|layer| (0..cfg.decoder_attention_heads).map(move |head| (layer, head))).collect()) +} + fn softmax_at(x: &[f32], i: usize) -> f32 { let m = x.iter().copied().fold(f32::NEG_INFINITY, f32::max); let s: f32 = x.iter().map(|v| (v - m).exp()).sum(); @@ -335,6 +361,27 @@ impl Transcriber for Whisper { mod tests { use super::*; + fn config(layers: usize, heads: usize) -> Config { + Config { + num_mel_bins: 80, + d_model: 384, + encoder_layers: 4, + encoder_attention_heads: 6, + decoder_layers: layers, + decoder_attention_heads: heads, + max_source_positions: 1_500, + max_target_positions: 448, + vocab_size: 51_865, + } + } + + #[test] + fn fallback_alignment_heads_are_bounded_before_collection() { + let error = alignment_heads(&config(usize::MAX, usize::MAX), &serde_json::Value::Null).unwrap_err(); + assert!(error.to_string().contains("alignment-head")); + assert_eq!(alignment_heads(&config(4, 6), &serde_json::Value::Null).unwrap().len(), 12); + } + #[test] fn repetition_guard() { let tb = 1000; diff --git a/crates/speech/src/whisper/tokenizer.rs b/crates/speech/src/whisper/tokenizer.rs index 2fa024b22..aaaa93b9c 100644 --- a/crates/speech/src/whisper/tokenizer.rs +++ b/crates/speech/src/whisper/tokenizer.rs @@ -9,6 +9,9 @@ use std::collections::HashMap; use crate::SpeechError; +/// Generous ceiling above every published Whisper vocabulary, before allocating a dense id table. +const MAX_VOCAB_ID: u64 = 1_000_000; + pub struct Tokenizer { /// Bytes of each regular token, by id. bytes: Vec>, @@ -48,7 +51,11 @@ impl Tokenizer { let v: serde_json::Value = serde_json::from_str(json).map_err(|e| bad(&e.to_string()))?; let vocab = v["model"]["vocab"].as_object().ok_or_else(|| bad("no model.vocab"))?; let table = unicode_to_byte(); - let max = vocab.values().filter_map(|x| x.as_u64()).max().unwrap_or(0) as usize; + let max = vocab.values().filter_map(|x| x.as_u64()).max().unwrap_or(0); + if max > MAX_VOCAB_ID { + return Err(bad("vocab id is too large")); + } + let max = usize::try_from(max).map_err(|_| bad("vocab id is too large"))?; let mut bytes = vec![Vec::new(); max + 1]; for (tok, id) in vocab { let id = id.as_u64().ok_or_else(|| bad("vocab id"))? as usize; @@ -118,6 +125,21 @@ impl Tokenizer { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sparse_huge_vocab_id_is_rejected_before_allocation() { + let json = format!(r#"{{"model":{{"vocab":{{"x":{}}}}},"added_tokens":[]}}"#, MAX_VOCAB_ID + 1); + let error = match Tokenizer::from_json(&json) { + Err(error) => error, + Ok(_) => panic!("huge sparse vocabulary was accepted"), + }; + assert!(error.to_string().contains("vocab id is too large")); + } +} + /// Group text tokens into words: a token whose text starts with a space starts a new word; /// punctuation-only tokens join the word before them (opening quotes/brackets the word after). /// Returns (word text, token index range). From 15ca07300bdef716d5bc9b40434ad25a5230a473 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:14:30 -0400 Subject: [PATCH 087/110] security(interchange): harden FCPXML cycle detection, XML DTD entities, and imported formats (#3079) --- crates/format/src/lib.rs | 11 ++++++++--- crates/format/tests/fixtures.rs | 14 ++++++++++++++ crates/interchange/src/fcpxml.rs | 12 ++++++++++-- crates/interchange/src/xml.rs | 17 +++++++++++++++++ crates/interchange/tests/it/fcpxml.rs | 18 ++++++++++++++++++ 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 6ee6e8043..aa19bb615 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -234,9 +234,14 @@ fn validate_loaded(mut loaded: Loaded) -> Result { for item in loaded.project.items.values_mut() { let name = item.name.clone(); match &mut item.kind { - filmcraft_project::ItemKind::Media(m) if m.info.audio_streams.len() > max => { - log::warn!("media `{name}`: {} audio streams, keeping the first {max}", m.info.audio_streams.len()); - m.info.audio_streams.truncate(max); + filmcraft_project::ItemKind::Media(m) => { + if let Some(video) = &m.info.video { + filmcraft_project::validate_frame_size(video.width, video.height).map_err(|e| FormatError::Corrupt(format!("media `{name}`: {e}")))?; + } + if m.info.audio_streams.len() > max { + log::warn!("media `{name}`: {} audio streams, keeping the first {max}", m.info.audio_streams.len()); + m.info.audio_streams.truncate(max); + } } filmcraft_project::ItemKind::Sequence(sequence) => { let sequence = std::sync::Arc::make_mut(sequence); diff --git a/crates/format/tests/fixtures.rs b/crates/format/tests/fixtures.rs index 6f1c9922a..fa1b7027d 100644 --- a/crates/format/tests/fixtures.rs +++ b/crates/format/tests/fixtures.rs @@ -85,6 +85,20 @@ fn hostile_audio_stream_counts_and_indices_are_repaired_not_rejected() { assert_eq!(loaded.item(ItemId(5)).unwrap().as_media().unwrap().info.audio_streams.len(), filmcraft_media::MAX_AUDIO_STREAMS); } +/// Persisted media metadata is used to allocate synthetic and offline source frames, so it must +/// have the same bounded dimensions as a sequence before the project is opened. +#[test] +fn hostile_media_video_dimensions_are_rejected() { + let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let media = p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap(); + let video = media.info.video.as_mut().unwrap(); + video.width = u32::MAX; + video.height = 1; + + let err = decode(&encode(&p, false)).unwrap_err(); + assert!(matches!(err, FormatError::Corrupt(ref message) if message.contains("media `Ocean_Sunset.mp4`") && message.contains("frame size")), "{err}"); +} + /// Schema 8 (before M3.10): no time interpolation / Hold Filters / Field Options / source channels /// on clips, no audio channel map, subclips without Restrict Trims. #[test] diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs index 438e22fbc..c75bdd651 100644 --- a/crates/interchange/src/fcpxml.rs +++ b/crates/interchange/src/fcpxml.rs @@ -14,7 +14,7 @@ //! clip markers. Transitions are supported on the primary storyline (and read from secondary //! storylines); transitions elsewhere are reported. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use filmcraft_geom::Vec2; use filmcraft_media::MediaKind; @@ -132,6 +132,7 @@ struct Imp<'a, 'i, 'r> { assets: HashMap, media_nodes: HashMap>, media_items: HashMap, + media_active: HashSet, effects: HashMap, } @@ -163,6 +164,7 @@ pub(crate) fn import(text: &str, opts: &ImportOptions, report: &mut Report) -> R assets: HashMap::new(), media_nodes: HashMap::new(), media_items: HashMap::new(), + media_active: HashSet::new(), effects: HashMap::new(), }; if let Some(res) = child(root, "resources") { @@ -281,9 +283,15 @@ impl<'a, 'i> Imp<'a, 'i, '_> { } let n = *self.media_nodes.get(id)?; let s = child(n, "sequence")?; - // Reserve first so recursion terminates. + if !self.media_active.insert(id.into()) { + self.report.warn(format!("recursive FCPXML media resource {id:?} ignored")); + return None; + } + // Mark the resource before descending: a compound clip can reference itself through a + // ref-clip before a completed sequence is available to cache. let name = n.attribute("name").unwrap_or("Compound Clip").to_string(); let item = self.sequence(s, &name, None); + self.media_active.remove(id); self.media_items.insert(id.into(), item); Some(item) } diff --git a/crates/interchange/src/xml.rs b/crates/interchange/src/xml.rs index 4d227be49..3bc1120bb 100644 --- a/crates/interchange/src/xml.rs +++ b/crates/interchange/src/xml.rs @@ -159,6 +159,17 @@ pub(crate) fn num(v: f64) -> String { } pub(crate) fn parse(text: &str) -> Result, String> { + // FCP7 XML commonly carries a plain `` declaration, which + // roxmltree accepts only with DTD parsing enabled. Internal subsets are not + // needed by any supported interchange format and can expand entities far + // beyond the input size, so reject them before handing the document to the + // parser. External entities remain unresolved because no resolver is set. + if let Some(start) = text.find("').map_or(text.len(), |end| start + end)]; + if declaration.contains('[') { + return Err("internal DTD subsets are not supported".into()); + } + } let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; roxmltree::Document::parse_with_options(text, opts).map_err(|e| e.to_string()) } @@ -196,4 +207,10 @@ mod tests { assert_eq!(a.attribute("name"), Some("x\ny\tz\r")); assert_eq!(child(a, "b").and_then(|b| b.text()), Some("a\nb")); } + + #[test] + fn rejects_internal_dtd_entity_expansion() { + let text = "]>&word;"; + assert_eq!(parse(text).unwrap_err(), "internal DTD subsets are not supported"); + } } diff --git a/crates/interchange/tests/it/fcpxml.rs b/crates/interchange/tests/it/fcpxml.rs index 2bf6f3c46..065c341fd 100644 --- a/crates/interchange/tests/it/fcpxml.rs +++ b/crates/interchange/tests/it/fcpxml.rs @@ -98,6 +98,24 @@ fn doc() -> String { ) } +#[test] +fn self_referencing_compound_media_is_ignored_without_recursing() { + let xml = r#" + + + + + + + + + + "#; + let (imported, report) = import(xml.as_bytes(), Format::Fcpxml, None).unwrap(); + assert!(!imported.sequences.is_empty()); + assert!(report.mentions("recursive FCPXML media"), "{report:?}"); +} + #[test] fn golden_connected_clips() { let d = doc(); From 057f460085c6c1c1f2335f1ba1c599a2ff36da67 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:15:45 -0400 Subject: [PATCH 088/110] security: harden developer tools, scripts, and testkit against hostile input (#3078) --- Cargo.lock | 1 + crates/av1/tools/extract_tables.py | 27 ++++++++++++++++++++- crates/testkit/Cargo.toml | 4 ++++ crates/testkit/src/fixtures.rs | 4 ++-- crates/testkit/src/golden.rs | 6 ++--- crates/testkit/src/oracle.rs | 6 ++--- packaging/macos/import-cert.sh | 2 +- xtask/src/main.rs | 38 +++++++++++++++++++++++++++--- xtask/src/parity.rs | 8 +++++++ 9 files changed, 82 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ce535a44..09b966946 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2292,6 +2292,7 @@ version = "0.12.0" dependencies = [ "chrono", "png", + "tempfile", ] [[package]] diff --git a/crates/av1/tools/extract_tables.py b/crates/av1/tools/extract_tables.py index a037a3765..b4df9155b 100644 --- a/crates/av1/tools/extract_tables.py +++ b/crates/av1/tools/extract_tables.py @@ -14,6 +14,8 @@ Only the specification text is read: Each table is emitted as a nested Rust array whose shape is checked against the declared dimensions; the element type is the narrowest of u8/i8/u16/i16/i32 that holds every value. """ +import ast +import operator import os import re import sys @@ -43,7 +45,30 @@ def ev(expr): e = expr.strip().replace("\\", "") e = re.sub(r"\b([A-Z][A-Z0-9_]*)\b", lambda m: str(consts[m.group(1)]) if m.group(1) in consts else m.group(1), e) e = e.replace("/", "//") - return int(eval(e, {"__builtins__": {}}, {})) + binary = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod, + ast.LShift: operator.lshift, + ast.RShift: operator.rshift, + ast.BitOr: operator.or_, + ast.BitAnd: operator.and_, + ast.BitXor: operator.xor, + } + unary = {ast.UAdd: operator.pos, ast.USub: operator.neg, ast.Invert: operator.invert} + + def calculate(node): + if isinstance(node, ast.Constant) and isinstance(node.value, int): + return node.value + if isinstance(node, ast.BinOp) and type(node.op) in binary: + return binary[type(node.op)](calculate(node.left), calculate(node.right)) + if isinstance(node, ast.UnaryOp) and type(node.op) in unary: + return unary[type(node.op)](calculate(node.operand)) + raise ValueError("unsupported specification expression") + + return int(calculate(ast.parse(e, mode="eval").body)) # --- constants: symbol table (section 3) --- diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index 3e3f1f1ff..f4da4a400 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -15,3 +15,7 @@ workspace = true [dependencies] png = "0.18" chrono = { workspace = true } + +[dev-dependencies] +tempfile = "3.27" + diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index 62e9a1449..22e3c7a88 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -122,8 +122,8 @@ mod tests { #[test] fn generate_is_cached_and_atomic() { - let dir = std::env::temp_dir().join(format!("filmcraft-testkit-gen-{}", std::process::id())); - let out = dir.join("f.bin"); + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("f.bin"); let _ = std::fs::remove_file(&out); assert!(generate(&out, |_| false).is_none()); assert!(!out.exists()); diff --git a/crates/testkit/src/golden.rs b/crates/testkit/src/golden.rs index 6d52c14d5..03655f483 100644 --- a/crates/testkit/src/golden.rs +++ b/crates/testkit/src/golden.rs @@ -348,8 +348,8 @@ mod tests { if blessing() { return; } - let dir = std::env::temp_dir().join(format!("filmcraft-testkit-golden-{}", std::process::id())); - let path = dir.join("ramp.png"); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ramp.png"); let img = ramp(32, 16, 255); write_png(&path, &img).unwrap(); assert!(check_golden(&path, &img, Tolerance::EXACT, "t", "unit test").is_ok()); @@ -360,7 +360,7 @@ mod tests { far.px.iter_mut().step_by(4).for_each(|v| *v = 255 - *v); let e = check_golden(&path, &far, Tolerance::RENDER, "t", "unit test").unwrap_err(); assert!(e.contains("PSNR"), "{e}"); - assert!(check_golden(&dir.join("missing.png"), &img, Tolerance::RENDER, "t", "unit test").unwrap_err().contains("FILMCRAFT_BLESS")); + assert!(check_golden(&dir.path().join("missing.png"), &img, Tolerance::RENDER, "t", "unit test").unwrap_err().contains("FILMCRAFT_BLESS")); } #[test] diff --git a/crates/testkit/src/oracle.rs b/crates/testkit/src/oracle.rs index 50a1ce22c..ba8981084 100644 --- a/crates/testkit/src/oracle.rs +++ b/crates/testkit/src/oracle.rs @@ -202,10 +202,8 @@ mod tests { } use std::ffi::OsString; - fn tmpdir(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("filmcraft-testkit-{tag}-{}", std::process::id())); - std::fs::create_dir_all(&d).unwrap(); - d + fn tmpdir(_tag: &str) -> PathBuf { + tempfile::tempdir().unwrap().keep() } #[test] diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 2acf4bf3d..4fa50e08f 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -19,7 +19,7 @@ fi TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" KEYCHAIN="$TMP/filmcraft-signing.keychain-db" -CERT="$TMP/filmcraft-signing.p12" +CERT="$(mktemp "$TMP/filmcraft-signing.XXXXXX.p12")" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" trap 'rm -f "$CERT"' EXIT diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e3f883e0f..98902837a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -375,9 +375,8 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { break; } } - let path = line.split_whitespace().nth(1).unwrap_or("/").split(['?', '#']).next().unwrap_or("/").to_string(); - let rel = if path == "/" { "index.html".to_string() } else { path.trim_start_matches('/').replace("..", "") }; - let file = dir.join(&rel); + let path = line.split_whitespace().nth(1).unwrap_or("/").split(['?', '#']).next().unwrap_or("/"); + let file = served_path(&dir, path); let (status, body) = match std::fs::read(&file) { Ok(b) => ("200 OK", b), Err(_) => ("404 Not Found", b"not found".to_vec()), @@ -403,6 +402,39 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } +fn served_path(root: &std::path::Path, request_path: &str) -> std::path::PathBuf { + use std::path::Component; + if request_path == "/" { + return root.join("index.html"); + } + let repeated_root = request_path.starts_with("//"); + let raw = request_path.strip_prefix('/').unwrap_or(request_path); + let relative = std::path::Path::new(raw); + let windows_prefix = raw.contains('\\') || raw.split('/').next().is_some_and(|part| part.ends_with(':')); + if !repeated_root && !windows_prefix && relative.components().all(|part| matches!(part, Component::Normal(_))) { + root.join(relative) + } else { + // A path that cannot name a child of the document root deliberately misses. + root.join(".invalid-request-path") + } +} + +#[cfg(test)] +mod serve_tests { + use super::served_path; + use std::path::Path; + + #[test] + fn preview_paths_cannot_escape_the_document_root() { + let root = Path::new("/dist"); + assert_eq!(served_path(root, "/"), root.join("index.html")); + assert_eq!(served_path(root, "/assets/app.js"), root.join("assets/app.js")); + for hostile in ["/../etc/passwd", "//etc/passwd", "/a/../../etc/passwd", r"C:\\Windows\\win.ini"] { + assert_eq!(served_path(root, hostile), root.join(".invalid-request-path")); + } + } +} + /// (crate, test target, test) whose ignored test builds that crate's fixture matrix. const FIXTURE_GENERATORS: &[(&str, &str, &str)] = &[ ("h264", "it", "conformance::generate_fixtures"), diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 774a0a76a..0b6f81b19 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -38,6 +38,8 @@ use serde_json::Value; const MAX_DEPTH: usize = 16; /// Most menu items accepted from a reference file. const MAX_ITEMS: usize = 20_000; +/// Most UTF-8 bytes retained in one expanded leaf path. +const MAX_PATH_BYTES: usize = 4 * 1024; /// Keys holding a node's label, and its children. const LABEL_KEYS: [&str; 3] = ["name", "title", "label"]; const CHILD_KEYS: [&str; 6] = ["children", "items", "submenu", "menu", "menuItems", "menu_items"]; @@ -303,6 +305,10 @@ fn push_leaf(prefix: &[String], label: &str, min: usize, out: &mut Vec= MAX_ITEMS { return Err(format!("reference has more than {MAX_ITEMS} items")); } + let bytes = prefix.iter().try_fold(label.trim().len(), |n, part| n.checked_add(part.len())).ok_or("reference path is too large")?; + if bytes > MAX_PATH_BYTES { + return Err(format!("reference path has more than {MAX_PATH_BYTES} bytes")); + } let mut path = prefix.to_vec(); path.push(label.trim().to_string()); if path.len() >= min.max(1) { @@ -622,6 +628,8 @@ mod tests { assert!(reference_paths(&json!(many), area).unwrap_err().contains("more than")); let bare: Vec = (0..MAX_ITEMS + 1).map(|i| format!("Effect {i}")).collect(); assert!(area == Area::Menus || reference_paths(&json!(bare), area).unwrap_err().contains("more than")); + let long = format!("File > {}", "x".repeat(MAX_PATH_BYTES)); + assert!(reference_paths(&json!([long]), area).unwrap_err().contains("bytes")); } // odd values are skipped, not trusted assert_eq!(reference_paths(&json!([1, null, true, {"name": 5}, ["File"]]), Area::Menus).unwrap(), Vec::>::new()); From b4901c5bce272c14f8820bdb319aec3021b43be2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:25:27 -0400 Subject: [PATCH 089/110] security(codecs): validate VP9 empty tiles and AV1 film grain scaling points (#3081) --- crates/av1/src/grain.rs | 35 +++++++++++++++++++++++++++++++++++ crates/av1/src/header.rs | 29 +++++++++++++++++++++++++++++ crates/vp9/src/decoder.rs | 1 + 3 files changed, 65 insertions(+) diff --git a/crates/av1/src/grain.rs b/crates/av1/src/grain.rs index d4baad621..677243644 100644 --- a/crates/av1/src/grain.rs +++ b/crates/av1/src/grain.rs @@ -133,6 +133,10 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader for i in 0..n - 1 { let dy = ys[i + 1] as i32 - ys[i] as i32; let dx = xs[i + 1] as i32 - xs[i] as i32; + // The header parser rejects non-increasing points; never divide by zero regardless. + if dx <= 0 { + continue; + } let delta = dy * ((65536 + (dx >> 1)) / dx); for x in 0..dx { l[xs[i] as usize + x as usize] = ys[i] as i32 + ((x * delta + 32768) >> 16); @@ -274,3 +278,34 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Regression: two equal scaling points divided by zero building the scaling lookup. + #[test] + fn equal_scaling_points_do_not_panic() { + let mut g = FilmGrainParams { apply_grain: true, num_y_points: 2, ..Default::default() }; + g.point_y_value[..2].copy_from_slice(&[64, 64]); + g.point_y_scaling[..2].copy_from_slice(&[20, 40]); + let (w, h) = (16usize, 16usize); + let mut pic = Picture { + width: w as u32, + height: h as u32, + bit_depth: 10, + subsampling_x: 1, + subsampling_y: 1, + mono_chrome: false, + planes: [vec![512; w * h], vec![512; w * h / 4], vec![512; w * h / 4]], + color_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + full_range: false, + pts: 0, + draft: false, + }; + apply(&mut pic, &g, &SequenceHeader::default()); + assert_eq!(pic.planes[0].len(), w * h); + } +} diff --git a/crates/av1/src/header.rs b/crates/av1/src/header.rs index 73d7ec437..c4e5f8a16 100644 --- a/crates/av1/src/header.rs +++ b/crates/av1/src/header.rs @@ -1251,6 +1251,9 @@ impl FrameHeader { g.point_y_value[i] = r.f(8)? as u8; g.point_y_scaling[i] = r.f(8)? as u8; } + if !strictly_increasing(&g.point_y_value[..g.num_y_points]) { + return Err(Error::Invalid("point_y_value")); + } g.chroma_scaling_from_luma = if seq.color.mono_chrome { false } else { r.flag()? }; if seq.color.mono_chrome || g.chroma_scaling_from_luma || (seq.color.subsampling_x == 1 && seq.color.subsampling_y == 1 && g.num_y_points == 0) { g.num_cb_points = 0; @@ -1264,6 +1267,9 @@ impl FrameHeader { g.point_cb_value[i] = r.f(8)? as u8; g.point_cb_scaling[i] = r.f(8)? as u8; } + if !strictly_increasing(&g.point_cb_value[..g.num_cb_points]) { + return Err(Error::Invalid("point_cb_value")); + } g.num_cr_points = r.f(4)? as usize; if g.num_cr_points > 10 { return Err(Error::Invalid("num_cr_points")); @@ -1272,6 +1278,9 @@ impl FrameHeader { g.point_cr_value[i] = r.f(8)? as u8; g.point_cr_scaling[i] = r.f(8)? as u8; } + if !strictly_increasing(&g.point_cr_value[..g.num_cr_points]) { + return Err(Error::Invalid("point_cr_value")); + } } g.grain_scaling_minus_8 = r.f(2)?; g.ar_coeff_lag = r.f(2)?; @@ -1322,6 +1331,12 @@ pub(crate) fn default_gm_params() -> [[i32; 6]; 8] { p } +/// Film grain scaling points must strictly increase (6.8.20); equal points would divide by zero when +/// the scaling lookup is interpolated. +fn strictly_increasing(values: &[u8]) -> bool { + values.windows(2).all(|w| w.first() < w.get(1)) +} + fn inverse_recenter(r: i32, v: i32) -> i32 { if v > 2 * r { v @@ -1359,3 +1374,17 @@ fn decode_subexp(r: &mut BitReader, num_syms: i32) -> Result { } } } + +#[cfg(test)] +mod tests { + use super::strictly_increasing; + + #[test] + fn film_grain_points_must_strictly_increase() { + assert!(strictly_increasing(&[])); + assert!(strictly_increasing(&[7])); + assert!(strictly_increasing(&[0, 64, 255])); + assert!(!strictly_increasing(&[0, 64, 64])); + assert!(!strictly_increasing(&[128, 64])); + } +} diff --git a/crates/vp9/src/decoder.rs b/crates/vp9/src/decoder.rs index e220a7f62..619d1b5c2 100644 --- a/crates/vp9/src/decoder.rs +++ b/crates/vp9/src/decoder.rs @@ -352,6 +352,7 @@ impl Decoder { ensure!(s <= data.len() - pos, "tile size {s} beyond frame data"); s }; + ensure!(sz > 0, "empty tile payload"); td.push(&data[pos..pos + sz]); pos += sz; } From 48c7a272262680d9b9c42c3bb075d71ed3e918cf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:26:28 -0400 Subject: [PATCH 090/110] security(engine): require local trust for ingest destinations (#3083) --- crates/engine/src/project_tools.rs | 2 +- crates/engine/src/proxies.rs | 24 +++++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index 136229370..ea19c6aa9 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -1027,7 +1027,7 @@ fn inside(dir: &std::path::Path, root: &std::path::Path) -> bool { /// Remember the Scratch Disks folders chosen in this session, so projects naming them may use /// them later (see [`trusted_scratch`]). -fn remember_scratch_folders(s: &mut Session, dirs: &[String]) { +pub(crate) fn remember_scratch_folders(s: &mut Session, dirs: &[String]) { const KEEP: usize = 64; let mut prefs = s.prefs.clone(); let list = &mut prefs.auto_save.scratch_folders; diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index 68204d2af..ad84893c6 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -508,13 +508,24 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { if !ing.enabled || items.is_empty() { return Ok(Value::Null); } + // Ingest settings travel in a project file. A destination only becomes active after this + // machine has chosen it, or when it is safely inside the project folder. + let destination = ing + .destination + .as_deref() + .filter(|d| !d.is_empty()) + .and_then(|d| crate::project_tools::trusted_scratch(Some(d), s.path.as_deref(), &s.prefs.auto_save.scratch_folders)); + if ing.destination.as_deref().is_some_and(|d| !d.is_empty()) && destination.is_none() { + s.toast("Ingest: ignoring an untrusted project destination; choose it locally in Ingest Settings to enable copying."); + return Ok(json!({"skipped": "untrusted ingest destination"})); + } let mut copied = Vec::new(); let copy = matches!(ing.action, IngestAction::Copy | IngestAction::CopyAndCreateProxies); if copy { for &i in items { let Some((_, path, _)) = media_path(&s.project, i) else { continue }; - let dir = match &ing.destination { - Some(d) if !d.is_empty() => PathBuf::from(d), + let dir = match &destination { + Some(d) => d.clone(), _ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"), }; if Path::new(&path).file_name().is_some_and(|name| Path::new(&path) == dir.join(name)) { @@ -527,7 +538,7 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { let mut job = Value::Null; match ing.action { IngestAction::CreateProxies | IngestAction::CopyAndCreateProxies => { - let p = json!({"items": items.iter().map(|i| i.0).collect::>(), "preset": if ing.preset.is_empty() { DEFAULT_PROXY_PRESET } else { ing.preset.as_str() }, "destination": ing.destination}); + let p = json!({"items": items.iter().map(|i| i.0).collect::>(), "preset": if ing.preset.is_empty() { DEFAULT_PROXY_PRESET } else { ing.preset.as_str() }, "destination": destination.as_ref().map(|d| d.to_string_lossy().into_owned())}); if items.iter().any(|i| s.project.item(*i).and_then(|x| x.as_media()).is_some_and(|m| m.info.video.is_some() && m.info.kind == MediaKind::Movie)) { job = create(s, &p)?; } @@ -542,8 +553,8 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { if clip.info.video.is_none() { continue; } - let dir = match &ing.destination { - Some(d) if !d.is_empty() => PathBuf::from(d), + let dir = match &destination { + Some(d) => d.clone(), _ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"), }; let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name); @@ -680,6 +691,9 @@ pub fn commands() -> Vec { } if let Some(d) = p.get("destination") { ing.destination = d.as_str().filter(|d| !d.is_empty()).map(str::to_string); + if let Some(d) = ing.destination.as_ref() { + crate::project_tools::remember_scratch_folders(s, std::slice::from_ref(d)); + } } if let Some(pr) = str_p(p, "preset") { if !pr.is_empty() && preset(pr).is_none() { From 552f008d2757e307194256b3e672c9c1de4fa9b2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:28:22 -0400 Subject: [PATCH 091/110] security(audio): bound sequence sample rates and AAF/OMF export channel buffers (#3082) * security(audio): bound sequence sample rates and AAF/OMF export channel buffers * fix(engine): bound export audio channels and samples independently --- crates/audio-dsp/src/resample.rs | 5 ++++- crates/engine/src/aaf_omf.rs | 10 +++++++++- crates/interchange/src/fcpxml.rs | 5 +++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/audio-dsp/src/resample.rs b/crates/audio-dsp/src/resample.rs index 01dfee55d..b1686583e 100644 --- a/crates/audio-dsp/src/resample.rs +++ b/crates/audio-dsp/src/resample.rs @@ -20,6 +20,9 @@ pub struct StreamResampler { next_out: Option, } +/// Maximum source frames fetched in a single resampler pull. +pub const MAX_RESAMPLE_SOURCE_SPAN: usize = 384_000 * 10; + impl StreamResampler { /// Rates of zero are treated as 1 Hz rather than dividing by zero. pub fn new(from: u32, to: u32) -> Self { @@ -53,7 +56,7 @@ impl StreamResampler { } let held_end = self.held_start + self.held.first().map_or(0, Vec::len) as i64; if last + 1 > held_end { - let want = (last + 1 - held_end) as usize; + let want = ((last + 1 - held_end) as usize).min(MAX_RESAMPLE_SOURCE_SPAN); let got = pull(held_end, want); if self.held.is_empty() { self.held = vec![Vec::new(); got.len().max(1)]; diff --git a/crates/engine/src/aaf_omf.rs b/crates/engine/src/aaf_omf.rs index 823b895dc..6aeafdcd2 100644 --- a/crates/engine/src/aaf_omf.rs +++ b/crates/engine/src/aaf_omf.rs @@ -130,6 +130,9 @@ fn dequantize(pcm: &[u8], channels: usize, bits: u16) -> Vec> { out } +const MAX_EXPORT_AUDIO_CHANNELS: usize = 64; +const MAX_EXPORT_AUDIO_SAMPLES: usize = 64 * 1024 * 1024; + /// Decode a media range at `sample_rate` (planar). fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result>> { let src = s @@ -138,7 +141,12 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result> = vec![Vec::with_capacity(frames); need.channels.max(1) as usize]; + let ch = (need.channels.max(1) as usize).min(MAX_EXPORT_AUDIO_CHANNELS); + let total_samples = ch.checked_mul(frames).ok_or_else(|| EngineError::Other("audio export buffer size overflow".into()))?; + if total_samples > MAX_EXPORT_AUDIO_SAMPLES { + return Err(EngineError::Other(format!("audio export buffer size {total_samples} samples exceeds maximum allowed ({MAX_EXPORT_AUDIO_SAMPLES})"))); + } + let mut out: Vec> = vec![Vec::with_capacity(frames); ch]; let chunk = sample_rate as usize * 10; let mut done = 0usize; while done < frames { diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs index c75bdd651..c50186081 100644 --- a/crates/interchange/src/fcpxml.rs +++ b/crates/interchange/src/fcpxml.rs @@ -105,10 +105,11 @@ fn audio_rate_str(sr: u32) -> String { fn parse_audio_rate(s: &str) -> Option { let s = s.trim(); - match s.strip_suffix('k') { + let r = match s.strip_suffix('k') { Some(k) => k.parse::().ok().map(|v| (v * 1000.0).round() as u32), None => s.parse().ok(), - } + }; + r.filter(|&sr| sr > 0 && sr <= 384_000) } // --------------------------------------------------------------------------------------------- From 1f6567021d78696ed7070d64dafc514fed80cd37 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sat, 24 Oct 2026 07:15:37 -0700 Subject: [PATCH 092/110] Import: numbered embedded-audio names keep the path's separators on Windows (#2988) When an AAF or OMF import finds its embedded-audio path taken, it writes the audio under a numbered name. The numbered path was built with `Path::with_file_name`, which on Windows joins the folder and the new name with `\` whatever separator the path was written with: `/files/cut Audio.wav` became `/files\cut Audio (2).wav`. A document imported by a forward-slash path stored media paths with mixed separators, and `virtual_extraction_keeps_existing_media` failed on Windows, because its in-memory files are keyed by string and the `(2)` name looked free. A numbered name now replaces only the file name and keeps the folder as written. New test `numbered_names_keep_the_path_separators` covers a `/` and a `\` path. --- crates/engine/src/interchange.rs | 46 ++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs index e0f3e82e9..a0ce36778 100644 --- a/crates/engine/src/interchange.rs +++ b/crates/engine/src/interchange.rs @@ -1,7 +1,5 @@ //! 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}; @@ -26,27 +24,23 @@ 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. +/// Write embedded audio without replacing an occupied path; return the path used. A numbered +/// name changes only the file name: the folder keeps its separators as written (`/` or `\`). fn write_embedded_audio(s: &Session, path: &str, wav: &[u8]) -> Result { - let original = Path::new(path); - let stem = original.file_stem().ok_or_else(|| EngineError::Other(format!("{path}: missing audio file name")))?; + let (folder, name) = path.split_at(path.rfind(['/', '\\']).map_or(0, |i| i + 1)); + if matches!(name, "" | "." | "..") { + return Err(EngineError::Other(format!("{path}: missing audio file name"))); + } + let (stem, extension) = match name.rfind('.') { + Some(i) if i > 0 => name.split_at(i), + _ => (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()), + let candidate = if number == 1 { path.to_owned() } else { format!("{folder}{stem} ({number}){extension}") }; + match s.services.write_file_new(&candidate, wav) { + Ok(()) => return Ok(candidate), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(e) => return Err(EngineError::Other(format!("{candidate_text}: {e}"))), + Err(e) => return Err(EngineError::Other(format!("{candidate}: {e}"))), } } Err(EngineError::Other(format!("{path}: no unused embedded-audio filename after 1000 attempts"))) @@ -237,4 +231,16 @@ mod embedded_audio_tests { assert_eq!(stored["/files/cut Audio (2).wav"], b"second"); assert_eq!(stored["/files/cut Audio (3).wav"], b"new audio"); } + + #[test] + fn numbered_names_keep_the_path_separators() { + for path in ["C:/files/cut Audio.wav", "C:\\files\\cut Audio.wav"] { + let files = Arc::new(VirtualFiles::default()); + files.0.lock().unwrap().insert(path.into(), b"first".to_vec()); + let session = Session::new(files.clone()); + let numbered = write_embedded_audio(&session, path, b"new audio").unwrap(); + assert_eq!(numbered, path.replace("cut Audio.wav", "cut Audio (2).wav")); + assert_eq!(files.0.lock().unwrap()[path], b"first"); + } + } } From ba4bf46b2842eb924afd31a531fed3eccc89b6a4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:15:42 +0200 Subject: [PATCH 093/110] Don't treat pieces with different settings as through edits (#3072) Found while testing crates/edit/src/through.rs:21 (is_through_edit) and :61-102 (join_through_edits_where) Co-authored-by: Claude Haiku 5.5 --- crates/edit/src/through.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/edit/src/through.rs b/crates/edit/src/through.rs index 9b662abf9..263bbcce0 100644 --- a/crates/edit/src/through.rs +++ b/crates/edit/src/through.rs @@ -22,6 +22,18 @@ pub fn is_through_edit(a: &TrackItem, b: &TrackItem) -> bool { if a.end() != b.start || a.item != b.item || a.speed != b.speed || a.reverse != b.reverse || a.multicam != b.multicam { return false; } + if a.enabled != b.enabled + || a.gain_db != b.gain_db + || a.effects != b.effects + || a.scale_to_frame != b.scale_to_frame + || a.hold_filters != b.hold_filters + || a.time_interpolation != b.time_interpolation + || a.field_options != b.field_options + || a.audio_stream != b.audio_stream + || a.source_channels != b.source_channels + { + return false; + } match (a.frame_hold, b.frame_hold) { (None, None) => {} (Some(x), Some(y)) => return x == y, @@ -214,6 +226,20 @@ mod tests { assert_eq!(q.video_tracks[0].items, orig.video_tracks[0].items); } + #[test] + fn pieces_with_different_settings_are_not_through_edits() { + let tweaks: [fn(&mut TrackItem); 3] = [|t| t.enabled = false, |t| t.gain_db = -20.0, |t| t.scale_to_frame = true]; + for tweak in tweaks { + let mut q = seq_with(vec![item(10, 0, 100, 500)]); + let mut n = 1000; + razor(&mut q, &[], Tick(40), &mut ctx(&mut n)); + tweak(&mut q.video_tracks[0].items[1]); + assert!(through_edits(&q).is_empty()); + assert_eq!(join_through_edits(&mut q, &[]), 0); + assert_eq!(q.video_tracks[0].items.len(), 2); + } + } + #[test] fn reversed_and_speed_changed_pieces_join() { for (speed, reverse) in [(1.0, true), (1.5, false), (0.7, true)] { From b8821192e6e1372c5efe6f40c4fb5e63c3d9e9a5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:15:46 +0200 Subject: [PATCH 094/110] Keep WebVTT cues whose identifier is STYLE or REGION (#3088) Found while testing crates/captions/src/vtt.rs:86 Co-authored-by: Claude Haiku 5.5 --- crates/captions/src/vtt.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/captions/src/vtt.rs b/crates/captions/src/vtt.rs index 9c2f3ae4e..0ad76615e 100644 --- a/crates/captions/src/vtt.rs +++ b/crates/captions/src/vtt.rs @@ -83,7 +83,7 @@ pub fn parse(text: &str) -> Result { doc.blocks.push(b.join("\n")); continue; } - if !seen_cue && !first.contains("-->") && (first == "STYLE" || first == "REGION") { + if !seen_cue && !b.iter().any(|l| l.contains("-->")) && (first == "STYLE" || first == "REGION") { doc.blocks.push(b.join("\n")); continue; } @@ -152,6 +152,15 @@ mod tests { const SAMPLE: &str = "WEBVTT - with header\nKind: captions\n\nSTYLE\n::cue { color: yellow }\n\nNOTE a comment\nspanning lines\n\nintro\n00:01.000 --> 00:00:03.500 line:90% align:start\nHello there\nsecond line\n\n00:00:04.000 --> 00:00:05.000\nplain & tags\n"; + #[test] + fn style_region_identifier_cue_is_kept() { + for id in ["STYLE", "REGION"] { + let d = parse(&format!("WEBVTT\n\n{id}\n00:00:01.000 --> 00:00:02.000\nHi\n")).unwrap(); + assert_eq!(d.cues.len(), 1, "{id}"); + assert!(d.blocks.is_empty(), "{id}"); + } + } + #[test] fn parse_sample() { let d = parse(SAMPLE).unwrap(); From 4c91c89fd893694f8a29c13dc888a972bae50f90 Mon Sep 17 00:00:00 2001 From: Adam Bruncaj <30725789+admbe@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:15:50 -0400 Subject: [PATCH 095/110] Say why H.265 and AV1 are unavailable without a hardware encoder (#3062) The Format list greyed H.265 and AV1 out as "not supported yet". It now gives the reason: they have no software encoder, and a hardware one (NVENC on NVIDIA, VideoToolbox on macOS) was not found. A preset that names such a format shows the same note under the Format row (export.format.unavailable) before the export would fail. Where the encoder exists, an NVENC H.265 export still works (ffprobe reads hvc1 Main 4:2:0 BT.709). Spanish, Japanese and Chinese for the two notes. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/zh-cn.tsv | 2 ++ crates/ui-egui/src/panels/export_mode.rs | 27 ++++++++++++++++++++++-- crates/ui-egui/tests/it/export_ui.rs | 10 +++++++++ 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ac92decc..214499151 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3617,3 +3617,5 @@ Open a sequence to search its graphics Abre una secuencia para buscar en sus gráficos No text graphics in this sequence. Add text with the Type tool. Esta secuencia no tiene gráficos con texto. Añade texto con la herramienta Texto. No matching text. Ningún texto coincide. + H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead. H.265 (HEVC) necesita un codificador por hardware (NVENC en GPU NVIDIA, VideoToolbox en macOS); no se encontró ninguno en este equipo y FilmCraft aún no tiene un codificador H.265 por software. Elija H.264 o ProRes. + AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet. AV1 necesita un codificador por hardware; no se encontró ninguno en este equipo y FilmCraft aún no tiene un codificador AV1 por software. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d0fb3d4e7..8446dac2b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3616,3 +3616,5 @@ Open a sequence to search its graphics シーケンスを開くとグラフィックスを検索できます No text graphics in this sequence. Add text with the Type tool. このシーケンスにはテキストのグラフィックスがありません。文字ツールでテキストを追加してください。 No matching text. 一致するテキストはありません。 + H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead. H.265 (HEVC) にはハードウェアエンコーダー(NVIDIA GPU の NVENC、macOS の VideoToolbox)が必要です。このマシンには見つからず、FilmCraft にはまだソフトウェアの H.265 エンコーダーがありません。代わりに H.264 または ProRes を選んでください。 + AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet. AV1 にはハードウェアエンコーダーが必要です。このマシンには見つからず、FilmCraft にはまだソフトウェアの AV1 エンコーダーがありません。 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 244646f47..647661bad 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3592,3 +3592,5 @@ Open a sequence to search its graphics 打开序列以搜索其中的图形 No text graphics in this sequence. Add text with the Type tool. 此序列中没有文本图形。请用文字工具添加文本。 No matching text. 没有匹配的文本。 + H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead. H.265 (HEVC) 需要硬件编码器(NVIDIA GPU 上的 NVENC、macOS 上的 VideoToolbox);本机未找到,且 FilmCraft 尚无软件 H.265 编码器。请改选 H.264 或 ProRes。 + AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet. AV1 需要硬件编码器;本机未找到,且 FilmCraft 尚无软件 AV1 编码器。 diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index f1cf5224d..6f944641b 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -336,6 +336,22 @@ fn combo_why(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options chosen } +/// Why a format cannot be exported on this machine, or None when it can. H.265 and AV1 have no +/// software encoder: without a hardware one (NVENC on NVIDIA, VideoToolbox on macOS) they are +/// greyed out with the reason, instead of failing at export time. +fn unavailable_why(format: Format) -> Option<&'static str> { + if filmcraft_engine::export::available(format) { + return None; + } + Some(match format { + Format::Hevc => tl!( + "H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead." + ), + Format::Av1 => tl!("AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet."), + _ => tl!("Not supported by FilmCraft's encoders yet"), + }) +} + fn opts(labels: &[&str]) -> Vec<(String, bool)> { labels.iter().map(|l| (l.to_string(), true)).collect() } @@ -552,8 +568,9 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); row(ui, &t, tl!("Format"), |ui| { - let labels: Vec<(String, bool)> = Format::ALL.iter().map(|f| (f.label().to_string(), filmcraft_engine::export::available(*f))).collect(); - if let Some(i) = combo(ui, &mut reg, "export.format", ex.settings.format.label(), &labels, ui.available_width() - 4.0) { + // a format without an encoder here says why: H.265 and AV1 have hardware encoders only + let labels: Vec<(String, Option<&str>)> = Format::ALL.iter().map(|f| (f.label().to_string(), unavailable_why(*f))).collect(); + if let Some(i) = combo_why(ui, &mut reg, "export.format", ex.settings.format.label(), &labels, ui.available_width() - 4.0) { let f = Format::ALL[i]; if f != ex.settings.format { // a format switch starts from that format's defaults (keeping size / rate / range) @@ -569,6 +586,12 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); + // a preset can name a format this machine cannot encode (an H.265 preset without a hardware + // encoder): say so where the format is chosen, before the export fails + if let Some(why) = unavailable_why(ex.settings.format) { + let r = ui.label(egui::RichText::new(why).color(t.danger).small()); + reg.add("export.format.unavailable", r.rect, why); + } ui.add_space(8.0); let s = &mut ex.settings; if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", tl!("Video"), &t) { diff --git a/crates/ui-egui/tests/it/export_ui.rs b/crates/ui-egui/tests/it/export_ui.rs index 6a3375259..573a36ee3 100644 --- a/crates/ui-egui/tests/it/export_ui.rs +++ b/crates/ui-egui/tests/it/export_ui.rs @@ -248,6 +248,16 @@ fn h265_shows_the_h264_family_controls_without_the_h264_only_ones() { } let sum = d.ok("ui.elements", json!({"prefix": "export.summary"}))[0]["label"].as_str().unwrap().to_string(); assert!(sum.contains("HEVC Main") && sum.contains("Target 20.00 Mbps"), "{sum}"); + // headless there is no hardware encoder: H.265 says so where the format is chosen (a preset + // can still name it), and its entry in the Format list is greyed out with the same reason + assert!(!filmcraft_engine::export::available(Format::Hevc), "no NVENC / VideoToolbox probe is registered here"); + let why = d.ok("ui.elements", json!({"prefix": "export.format.unavailable"}))[0]["label"].as_str().unwrap().to_string(); + assert!(why.contains("hardware encoder") && why.contains("H.265") && why.contains("H.264 or ProRes"), "{why}"); + d.click("export.format"); + let hevc = Format::ALL.iter().position(|f| *f == Format::Hevc).unwrap(); + let entries = d.ok("ui.elements", json!({"prefix": format!("export.format.option.{hevc}")})); + assert_eq!(entries[0]["label"], "H.265 (HEVC)", "{entries}"); + d.ok("ui.key", json!({"key": "Escape"})); d.snapshot("export-h265"); } From 8bf939182e1344186f44156ce69a3571f5021006 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:15:54 +0200 Subject: [PATCH 096/110] Only materialise kept frames when filling an H.264 frame_num gap (#2995) Fixes #2779 Co-authored-by: Claude Opus 5.5 --- crates/h264/src/dpb.rs | 76 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/h264/src/dpb.rs b/crates/h264/src/dpb.rs index df0385184..c712b0424 100644 --- a/crates/h264/src/dpb.rs +++ b/crates/h264/src/dpb.rs @@ -138,8 +138,19 @@ impl Dpb { poc_state: &mut dyn FnMut(u32), ) { let mut unused = (prev_ref_frame_num + 1) % max_frame_num; + // Missing frames before the last `max_num_ref_frames` would be dropped by the sliding window + // (oldest FrameNumWrap first) before the gap ends, so only their POC / frame_num bookkeeping + // runs; building pictures for them would cost up to max_frame_num frames per access unit. + let gap = if frame_num >= unused { frame_num - unused } else { (frame_num % max_frame_num) + max_frame_num - unused }; + let skip = gap.saturating_sub(max_num_ref_frames.max(1).try_into().unwrap_or(u32::MAX)); let mut guard = 0; while unused != frame_num && guard < max_frame_num { + if guard < skip { + poc_state(unused); + unused = (unused + 1) % max_frame_num; + guard += 1; + continue; + } self.sliding_window(unused, max_frame_num, max_num_ref_frames); let (frame, poc, meta) = make_frame(unused); poc_state(unused); @@ -419,3 +430,68 @@ impl Default for Dpb { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn meta() -> Arc { + Arc::new(OutputMeta { + pts: 0, + key: false, + crop: (0, 0, 0, 0), + full_range: false, + colour_primaries: 2, + transfer_characteristics: 2, + matrix_coefficients: 2, + sar: (1, 1), + draft: false, + }) + } + + /// A huge frame_num jump must only materialise the frames the sliding window keeps (#2779). + #[test] + fn frame_num_gap_materialises_at_most_max_num_ref_frames() { + let mut dpb: Dpb = Dpb::new(); + let m = meta(); + let mut made = 0u32; + let mut make = |f: u32| { + made += 1; + (f, 0, m.clone()) + }; + let mut updated = Vec::new(); + let mut upd = |f: u32| updated.push(f); + dpb.fill_frame_num_gap(0, 32768, 1 << 16, 4, &mut make, &mut upd); + assert!(made <= 4, "gap fill materialised {made} frames, expected <= 4"); + assert_eq!(updated, (1..32768).collect::>()); + let mut refs: Vec = dpb.entries.iter().filter(|e| e.mark == RefMark::Short).map(|e| e.frame_num).collect(); + refs.sort_unstable(); + assert_eq!(refs, vec![32764, 32765, 32766, 32767]); + assert!(dpb.entries.iter().all(|e| e.frame == e.frame_num)); + } + + /// Existing short-term references are still evicted by a gap no longer than the window. + #[test] + fn short_frame_num_gap_keeps_sliding_window_semantics() { + let mut dpb: Dpb = Dpb::new(); + let m = meta(); + for f in 0..2 { + dpb.entries.push(DpbEntry { + frame: f, + poc: 0, + frame_num: f, + long_term_frame_idx: 0, + mark: RefMark::Short, + needed_for_output: false, + non_existing: false, + meta: m.clone(), + }); + } + let mut make = |f: u32| (f, 0, m.clone()); + let mut upd = |_f: u32| {}; + dpb.fill_frame_num_gap(1, 5, 16, 4, &mut make, &mut upd); + let mut refs: Vec = dpb.entries.iter().filter(|e| e.mark == RefMark::Short).map(|e| e.frame_num).collect(); + refs.sort_unstable(); + assert_eq!(refs, vec![1, 2, 3, 4]); + } +} From 87b093de61125b7c062f9ba7d088292246e88c1c Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:15:57 -0500 Subject: [PATCH 097/110] CTL2807: release idle control connections after bounded socket waits (#2989) Set two-minute read/write timeouts and close expired connections quietly. Keep partial requests out of the UI queue and preserve the separate UI reply wait and active connection reuse. Fixes #2807. Co-authored-by: Codex --- apps/filmcraft/src/control_server.rs | 142 ++++++++++++++++++++++++++- docs/control-protocol.md | 5 + 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/apps/filmcraft/src/control_server.rs b/apps/filmcraft/src/control_server.rs index af1876f7b..cba27a6b0 100644 --- a/apps/filmcraft/src/control_server.rs +++ b/apps/filmcraft/src/control_server.rs @@ -24,6 +24,8 @@ use subtle::ConstantTimeEq; pub const MAX_LINE: usize = 4 * 1024 * 1024; /// Connections served at once; further ones get an error line and are closed. pub const MAX_CONNECTIONS: usize = 16; +/// A peer that stops sending or reading must eventually give back its connection slot. +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(120); pub fn start(port: u16, ctx: egui::Context) -> Receiver { start_with_token(port, ctx, std::env::var("FILMCRAFT_CONTROL_TOKEN").ok()) @@ -83,13 +85,22 @@ impl Drop for Slot { } fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context, token: &str) { - let Ok(read) = stream.try_clone() else { return }; + if let Err(e) = serve_with_timeout(stream, tx, ctx, token, CONNECTION_TIMEOUT) { + eprintln!("filmcraft: could not configure control connection: {e}"); + } +} + +fn serve_with_timeout(stream: TcpStream, tx: &Sender, ctx: &egui::Context, token: &str, timeout: Duration) -> std::io::Result<()> { + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + let read = stream.try_clone()?; serve_lines(read, stream, token, |method, params| { let (req, rrx) = ControlRequest::new(method, params); tx.send(req).ok()?; ctx.request_repaint(); Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))) }); + Ok(()) } /// One parsed line. @@ -131,13 +142,22 @@ fn parse_line(bytes: &[u8], token: &str) -> Line { Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) } } +#[derive(Debug, PartialEq, Eq)] +enum ReadFailure { + Timeout, + Rejected(String), +} + /// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). /// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. -fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { +fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { buf.clear(); // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); - let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; + let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() { + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ReadFailure::Timeout, + _ => ReadFailure::Rejected(e.to_string()), + })?; if n == 0 { return Ok(false); } @@ -149,7 +169,7 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) - return Ok(true); } if buf.len() > max { - return Err(format!("request line longer than {max} bytes; closing the connection")); + return Err(ReadFailure::Rejected(format!("request line longer than {max} bytes; closing the connection"))); } // Last line without a newline, then end of stream. Ok(true) @@ -164,7 +184,10 @@ fn serve_lines(read: R, mut out: W, token: &str, mut handle: loop { let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) { Ok(false) => return, - Err(e) => e, + // Expiration is not a request: a pooled client must see EOF and reconnect, rather + // than read a stale error reply. A partial line must not be parsed on timeout. + Err(ReadFailure::Timeout) => return, + Err(ReadFailure::Rejected(e)) => e, Ok(true) => match parse_line(&buf, token) { Line::Blank => continue, Line::Reject(e) => e, @@ -312,6 +335,115 @@ mod tests { drop(held); } + /// Idle and unterminated requests must release a counted connection without client EOF. + fn timed_out_connection_releases_slot(partial: &[u8]) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let open = Arc::new(AtomicUsize::new(1)); + let counted = Arc::clone(&open); + let (tx, rx) = channel::(); + let (done, finished) = channel(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + { + let _slot = Slot(counted); + serve_with_timeout(stream, &tx, &egui::Context::default(), TOKEN, Duration::from_millis(100)).unwrap(); + } + done.send(()).unwrap(); + }); + let mut client = TcpStream::connect(addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + client.write_all(partial).unwrap(); + let completion = finished.recv_timeout(Duration::from_secs(5)); + let mut unsolicited = String::new(); + if completion.is_ok() { + client.read_to_string(&mut unsolicited).unwrap(); + } + // Always unblock and join the test worker before asserting, even on the unfixed server. + let unexpected_command = rx.try_recv().is_ok(); + drop(rx); + drop(client); + server.join().unwrap(); + assert!(completion.is_ok(), "an idle peer held its connection slot until the client closed"); + assert!(unsolicited.is_empty(), "expiration must not leave a stale reply for a pooled client: {unsolicited}"); + assert_eq!(open.load(Ordering::SeqCst), 0); + assert!(!unexpected_command, "an incomplete request must not reach the UI queue"); + } + + #[test] + /// A silent unauthenticated peer must not retain control-server capacity. + fn idle_connection_releases_its_slot() { + timed_out_connection_releases_slot(b""); + } + + #[test] + /// Sending a partial JSON line must not bypass idle expiration. + fn unterminated_request_releases_its_slot() { + timed_out_connection_releases_slot(b"{\"method\":"); + } + + #[test] + /// A timeout is not EOF: valid JSON without its newline must not be dispatched. + fn valid_unterminated_request_is_not_dispatched() { + let request = json!({"id": 1, "token": TOKEN, "method": "ui.inspect"}).to_string(); + timed_out_connection_releases_slot(request.as_bytes()); + } + + #[test] + /// Waiting for the UI is separate from socket idleness; buffered requests remain answerable. + fn idle_timeout_does_not_cancel_a_pending_ui_reply() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + serve_with_timeout(stream, &tx, &egui::Context::default(), TOKEN, Duration::from_millis(100)).unwrap(); + }); + let mut client = TcpStream::connect(addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut read = BufReader::new(client.try_clone().unwrap()); + // Buffer complete requests, as the bridge does. Queue both before waiting for replies: + // client scheduling after reply 1 must not create a genuine idle gap in this test. + let requests: String = [1, 2].into_iter().map(|id| format!("{}\n", json!({"token": TOKEN, "id": id, "method": "ui.inspect"}))).collect(); + client.write_all(requests.as_bytes()).unwrap(); + for id in [1, 2] { + let request = rx.recv_timeout(Duration::from_secs(5)).unwrap_or_else(|e| panic!("request {id} did not reach the UI: {e}")); + // Deliberately exceed the read timeout while the server is awaiting a UI reply. + std::thread::sleep(Duration::from_millis(250)); + request.reply.send(json!({"ok": true})).unwrap(); + let mut line = String::new(); + read.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap(), json!({"id": id, "ok": true})); + } + client.shutdown(std::net::Shutdown::Write).unwrap(); + server.join().unwrap(); + } + + #[test] + /// An active client can send another request after consuming its previous reply. + fn active_connection_can_be_reused() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + serve_with_timeout(stream, &tx, &egui::Context::default(), TOKEN, Duration::from_secs(5)).unwrap(); + }); + let mut client = TcpStream::connect(addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut read = BufReader::new(client.try_clone().unwrap()); + for id in [1, 2] { + let line = format!("{}\n", json!({"token": TOKEN, "id": id, "method": "ui.inspect"})); + client.write_all(line.as_bytes()).unwrap(); + rx.recv_timeout(Duration::from_secs(5)).unwrap().reply.send(json!({"ok": true})).unwrap(); + let mut reply = String::new(); + read.read_line(&mut reply).unwrap(); + assert_eq!(serde_json::from_str::(&reply).unwrap(), json!({"id": id, "ok": true})); + } + client.shutdown(std::net::Shutdown::Write).unwrap(); + server.join().unwrap(); + } + #[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))] { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9cdb36791..6093c4fbd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -18,6 +18,11 @@ the desktop leaves the listener closed. Every raw JSON request must include the `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. +Socket reads and writes time out after two minutes without progress, releasing the connection slot. +Expiration closes the connection without an unsolicited reply; reconnect before the next request +(the CLI / MCP bridge does this automatically). Waiting for a UI reply has its separate 60-second +limit. A partial request that times out is not dispatched. + 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. From bb66ec17c422b9eb11cfe3fc50276612940f4513 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:16:01 -0500 Subject: [PATCH 098/110] HEVC2797: share immutable gray rows across unavailable references (#2992) Keep unique frame identities while lazily caching one gray backing for the active geometry and plane depths. Invalidate it on format changes and release it on flush without invalidating readers. Fixes #2797. Co-authored-by: Codex --- crates/hevc/README.md | 7 +- crates/hevc/src/decoder.rs | 146 ++++++++++++++++++++++++++++++++- crates/hevc/src/picture.rs | 26 +++++- crates/hevc/src/synth_tests.rs | 22 +++++ 4 files changed, 195 insertions(+), 6 deletions(-) diff --git a/crates/hevc/README.md b/crates/hevc/README.md index 59c762aed..347037d02 100644 --- a/crates/hevc/README.md +++ b/crates/hevc/README.md @@ -1,7 +1,7 @@ # filmcraft-hevc Clean-room, pure-Rust (no `unsafe`) H.265 / HEVC decoder, implemented from the public ITU-T -Rec. H.265 (ISO/IEC 23008-2) specification. It is an L0 crate: it depends only on +Rec. H.265 (08/2021, ISO/IEC 23008-2) specification. It is an L0 crate: it depends only on `filmcraft-bitstream`, `thiserror`, and optionally `rayon` (default feature `threads`), and builds for `wasm32-unknown-unknown`. @@ -69,6 +69,11 @@ the memory traffic in prediction reads, in-loop filters and output) and as `u16` slice decoder and the filters are generic over the sample type (`picture::Sample`), monomorphised once per type. +Unavailable references keep distinct frame IDs and POCs while sharing one immutable gray backing +for the active picture format. This backing is created only when a reference is missing, replaced +on geometry or plane-depth changes, and released on flush. Jobs still reading an older format keep +its rows alive. Each backing retains the full coded picture dimensions. + Draft mode (`Decoder::set_draft(true)`; FilmCraft enables it with Settings ▸ Playback ▸ Draft decoding while the Program monitor plays at 1/2 or 1/4): sub-layer non-reference pictures (TRAIL_N, RASL_N, ...) of the highest temporal sub-layer — no later picture predicts from them, diff --git a/crates/hevc/src/decoder.rs b/crates/hevc/src/decoder.rs index d6fcbbea9..f3a56f2d7 100644 --- a/crates/hevc/src/decoder.rs +++ b/crates/hevc/src/decoder.rs @@ -77,6 +77,8 @@ pub struct Decoder { pending: Option, next_id: u32, active_sps: Option>, + /// One lazily allocated gray backing for unavailable references in the active format. + gray_reference: Option, first_picture: bool, after_eos: bool, /// RASL pictures associated with the last IRAP are skipped (it had NoRaslOutputFlag = 1). @@ -132,6 +134,7 @@ impl Decoder { pending: None, next_id: 1, active_sps: None, + gray_reference: None, first_picture: true, after_eos: false, skip_rasl: false, @@ -225,6 +228,7 @@ impl Decoder { f.wait_complete(); } self.first_picture = true; + self.gray_reference = None; pics } @@ -392,6 +396,9 @@ impl Decoder { if changed && self.active_sps.is_some() { self.dpb.flush(&mut outs); } + if changed { + self.gray_reference = None; + } self.active_sps = Some(sps.clone()); self.dpb.max_dec_pic_buffering = sps.max_dec_pic_buffering as usize; self.dpb.max_num_reorder = sps.max_num_reorder as usize; @@ -412,12 +419,14 @@ impl Decoder { let (w, h) = (sps.width as usize, sps.height as usize); let template = Frame::new(0, 0, w, h, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma); let next_id = &mut self.next_id; + let gray_reference = &mut self.gray_reference; let mut missing = 0u64; let mut make_missing = |p: i32| -> FrameRef { missing += 1; let id = *next_id; *next_id += 1; - Arc::new(Frame::gray(id, p, &template)) + let gray = gray_reference.get_or_insert_with(|| Frame::gray(0, 0, &template)); + Arc::new(gray.with_identity(id, p)) }; let rps = self.dpb.apply_rps(sh, poc, max_lsb, irap_no_rasl, &mut make_missing)?; if missing > 0 { @@ -635,6 +644,141 @@ fn make_picture(o: &Output) -> Picture { mod tests { use super::*; + fn missing_refs(decoder: &mut Decoder, poc: u32, deltas: &[i32]) -> Vec { + let stream = crate::synth_tests::missing_reference_picture(poc, deltas); + for nal in annexb_nals(&stream) { + decoder.handle_nal(nal, i64::from(poc)).unwrap(); + } + decoder.pending.as_ref().unwrap().rps.st_curr_before.iter().map(|r| r.frame.clone()).collect() + } + + fn assert_shared_gray_rows(a: &Frame, b: &Frame) { + fn check(a: &Frame, b: &Frame) { + let (gy, gc) = (1u16 << (a.bit_depth - 1), 1u16 << (a.bit_depth_c - 1)); + for row in 0..a.num_rows() { + let a = a.row::(row).unwrap(); + let b = b.row::(row).unwrap(); + assert!(a.y.iter().all(|v| v.to_u16() == gy)); + assert!(a.cb.iter().chain(a.cr.iter()).all(|v| v.to_u16() == gc)); + assert!(a.col.iter().all(|v| v.flags == 0)); + assert!(std::ptr::eq(a.y.as_ref(), b.y.as_ref()), "missing references duplicated luma row {row}"); + assert!(std::ptr::eq(a.cb.as_ref(), b.cb.as_ref()), "missing references duplicated Cb row {row}"); + assert!(std::ptr::eq(a.cr.as_ref(), b.cr.as_ref()), "missing references duplicated Cr row {row}"); + assert!(std::ptr::eq(a.col.as_ref(), b.col.as_ref()), "missing references duplicated motion row {row}"); + } + } + assert_eq!(a.num_rows(), b.num_rows()); + assert!(a.is_complete() && b.is_complete()); + assert_eq!((a.width, a.height, a.log2_ctb, a.bit_depth, a.bit_depth_c), (b.width, b.height, b.log2_ctb, b.bit_depth, b.bit_depth_c)); + if stores_u8(a.bit_depth, a.bit_depth_c) { + check::(a, b); + } else { + check::(a, b); + } + } + + fn backing(frame: &Frame) -> usize { + if stores_u8(frame.bit_depth, frame.bit_depth_c) { + frame.row::(0).unwrap().y.as_ptr() as usize + } else { + frame.row::(0).unwrap().y.as_ptr() as usize + } + } + + #[test] + /// Missing references keep their identities while sharing one small immutable gray backing. + fn missing_reference_rows_are_shared_with_distinct_identities() { + for threads in [1, 4] { + let mut decoder = Decoder::with_threads(threads); + let refs = missing_refs(&mut decoder, 4, &[-1, -2]); + assert_eq!(refs.len(), 2); + assert_eq!((refs[0].poc, refs[1].poc), (3, 2)); + assert_ne!(refs[0].id, refs[1].id); + assert_shared_gray_rows(&refs[0], &refs[1]); + } + } + + #[test] + /// Concurrent/pending pictures must not each allocate their own full gray samples. + fn missing_reference_rows_are_shared_across_pictures() { + for threads in [1, 4] { + let mut decoder = Decoder::with_threads(threads); + let first = missing_refs(&mut decoder, 4, &[-1, -2]); + let second = missing_refs(&mut decoder, 5, &[-2, -3]); + assert_eq!((first.len(), second.len()), (2, 2)); + assert_eq!((second[0].poc, second[1].poc), (3, 2)); + let ids: std::collections::HashSet<_> = first.iter().chain(second.iter()).map(|f| f.id).collect(); + assert_eq!(ids.len(), 4); + for frame in first.iter().chain(second.iter()) { + assert_shared_gray_rows(&first[0], frame); + } + let pictures = decoder.flush(); + assert_eq!(pictures.len(), 2); + for picture in pictures { + assert!(picture.y.as_u8().unwrap().iter().chain(picture.u.as_u8().unwrap()).chain(picture.v.as_u8().unwrap()).all(|&v| v == 128)); + } + assert!(decoder.take_error().is_none()); + assert_eq!(decoder.stats().missing_refs, 4); + } + } + + #[test] + /// Geometry and independent plane-depth changes replace the cache without invalidating readers. + fn missing_reference_cache_tracks_format_and_keeps_old_rows_alive() { + let mut decoder = Decoder::with_threads(1); + let mut held = missing_refs(&mut decoder, 4, &[-1, -2]); + let original = decoder.pending.take().unwrap(); + let mut previous = (96, 64, 4, 8, 8); + let formats = [ + (96, 64, 4, 8, 8), + (128, 64, 4, 8, 8), + (128, 32, 4, 8, 8), + (128, 32, 5, 8, 8), + (128, 32, 5, 10, 8), + (128, 32, 5, 10, 10), + (128, 32, 5, 8, 10), + (128, 32, 5, 8, 8), + ]; + for format in formats { + let mut sps = (*original.sps).clone(); + (sps.width, sps.height, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma) = format; + decoder.start_picture(&original.first_sh, &Arc::new(sps), &original.pps, 0).unwrap(); + let pending = decoder.pending.take().unwrap(); + let refs: Vec<_> = pending.rps.st_curr_before.iter().map(|r| r.frame.clone()).collect(); + assert_shared_gray_rows(&refs[0], &refs[1]); + if format == previous { + assert_eq!(backing(held.last().unwrap()), backing(&refs[0])); + } else { + assert_ne!(backing(held.last().unwrap()), backing(&refs[0])); + } + assert_ne!(held.last().unwrap().id, refs[0].id); + held.extend(refs); + previous = format; + } + for frame in held { + assert_shared_gray_rows(&frame, &frame); + } + } + + #[test] + /// Ordinary streams allocate no gray cache; flush releases it even with retained reference rows. + fn missing_reference_cache_is_lazy_and_released_on_flush() { + let mut decoder = Decoder::with_threads(1); + let stream = crate::synth_tests::missing_reference_picture(0, &[]); + let mut pictures = decoder.decode(&stream, 0).unwrap(); + assert!(decoder.gray_reference.is_none()); + pictures.extend(decoder.flush()); + assert_eq!(pictures.len(), 1); + let refs = missing_refs(&mut decoder, 4, &[-1, -2]); + assert!(decoder.gray_reference.is_some()); + assert_eq!(decoder.flush().len(), 1); + assert!(decoder.gray_reference.is_none()); + let next = missing_refs(&mut decoder, 7, &[-1, -2]); + assert_ne!(backing(&refs[0]), backing(&next[0])); + assert_shared_gray_rows(&refs[0], &refs[1]); + assert_shared_gray_rows(&next[0], &next[1]); + } + /// The branch-free block counts classify every flag combination as the if / else-if chain did. #[test] fn block_counts_match_the_classification_chain() { diff --git a/crates/hevc/src/picture.rs b/crates/hevc/src/picture.rs index ffc7ee077..c87f919f5 100644 --- a/crates/hevc/src/picture.rs +++ b/crates/hevc/src/picture.rs @@ -61,7 +61,7 @@ impl Sample for u8 { } } fn wrap_rows(rows: Box<[OnceLock>]>) -> Rows { - Rows::U8(rows) + Rows::U8(rows.into()) } } @@ -94,7 +94,7 @@ impl Sample for u16 { } } fn wrap_rows(rows: Box<[OnceLock>]>) -> Rows { - Rows::U16(rows) + Rows::U16(rows.into()) } } @@ -114,9 +114,10 @@ pub struct FrameRow { } /// The CTB rows of a frame, in its storage type. +#[derive(Clone)] pub enum Rows { - U8(Box<[OnceLock>]>), - U16(Box<[OnceLock>]>), + U8(Arc<[OnceLock>]>), + U16(Arc<[OnceLock>]>), } /// A decoded (or in-progress) picture as stored in the DPB and referenced by later pictures. @@ -391,6 +392,23 @@ impl Frame { f } + /// A distinct logical frame sharing this frame's rows. Used for fully published, immutable + /// gray references: deblocking needs a unique frame ID even when their samples are identical. + pub fn with_identity(&self, id: u32, poc: i32) -> Frame { + Frame { + id, + poc, + width: self.width, + height: self.height, + cwidth: self.cwidth, + cheight: self.cheight, + log2_ctb: self.log2_ctb, + bit_depth: self.bit_depth, + bit_depth_c: self.bit_depth_c, + rows: self.rows.clone(), + } + } + /// A frame with every row published from full-picture planes and no motion. #[cfg(test)] pub fn from_planes(id: u32, poc: i32, like: &Frame, y: &[u16], cb: &[u16], cr: &[u16]) -> Frame { diff --git a/crates/hevc/src/synth_tests.rs b/crates/hevc/src/synth_tests.rs index aee0419fe..839810183 100644 --- a/crates/hevc/src/synth_tests.rs +++ b/crates/hevc/src/synth_tests.rs @@ -457,6 +457,28 @@ fn whole(n: usize) -> Vec { vec![Segment { start: 0, len: n, dependent: false }] } +/// Tiny parsed-picture fixture for missing-reference storage regressions in the decoder. +pub(crate) fn missing_reference_picture(poc: u32, deltas: &[i32]) -> Vec { + let mut stream = Vec::new(); + stream.extend(vps()); + stream.extend(sps(true)); + stream.extend(pps(0, &None, false)); + let picture = PicDesc { + nal_type: 1, + poc, + intra: deltas.is_empty(), + cu: if deltas.is_empty() { Cu::Pcm } else { Cu::Skip }, + pps_id: 0, + st: deltas.iter().map(|&d| (d, true)).collect(), + lt: vec![], + num_ref: deltas.len() as u32, + list_entry: None, + segments: whole(WC * HC), + }; + encode_picture(&picture, &None, &content(0), &mut stream, &mut None); + stream +} + /// Encode the test sequence; returns the stream and the exact expected pictures (when the PCM samples /// are not filtered). fn sequence(tiles: &Tiles, pcm_lf_disabled: bool, segments: Vec) -> (Vec, Vec) { From fd7ac2fe4fc7b7fd369c5a67cd8d069b35d5a0c5 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:16:05 -0500 Subject: [PATCH 099/110] REC2792: retain recovery candidates when discard fails (#2993) Stop deleting after the first filesystem error and restore failed, unprocessed and unselected candidates before returning it. Keep completed deletions removed. Three real filesystem regressions reproduced list loss. Fourteen autosave tests, formatting and strict engine lint pass; full CI runs on GitHub. Refs #2792. Co-authored-by: Codex --- crates/engine/src/autosave_tests.rs | 96 +++++++++++++++++++++++++++++ crates/engine/src/commands.rs | 17 +++-- docs/project-files.md | 2 + 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index 4ac6346a4..aad4adc20 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -35,6 +35,102 @@ fn journaled(s: &mut Session) -> u64 { s.persistence.as_ref().unwrap().status.journaled_revision } +fn discard_fixture(tag: &str) -> (Session, std::path::PathBuf) { + let d = temp_dir(tag); + let mut s = Session::default(); + start(&mut s, &d); + let candidates = ["first", "second", "third"] + .into_iter() + .map(|id| { + let dir = d.join(id); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("snapshot.fcproj"), b"retained snapshot").unwrap(); + autosave::RecoveryCandidate { id: id.into(), dir, meta: Default::default() } + }) + .collect(); + s.persistence.as_mut().unwrap().candidates = candidates; + (s, d) +} + +fn block_discard(dir: &std::path::Path) { + // An ordinary file cannot be removed as a directory, on Windows or Unix. + std::fs::remove_dir_all(dir).unwrap(); + std::fs::write(dir, b"occupied directory path").unwrap(); +} + +fn recovery_ids(s: &mut Session) -> Vec { + s.execute("file.recoveryList", json!({})).unwrap().as_array().unwrap().iter().map(|v| v["id"].as_str().unwrap().to_owned()).collect() +} + +#[test] +fn discard_failure_preserves_candidates_and_allows_retry() { + let (mut s, d) = discard_fixture("discard-first-error"); + block_discard(&d.join("first")); + assert!(s.execute("file.discardRecovery", json!({"all": true})).is_err()); + assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]); + assert!(s.is_enabled("file.discardRecovery")); + assert!(d.join("second/snapshot.fcproj").exists()); + assert!(d.join("third/snapshot.fcproj").exists()); + std::fs::remove_file(d.join("first")).unwrap(); + std::fs::create_dir(d.join("first")).unwrap(); + assert_eq!(s.execute("file.discardRecovery", json!({"all": true})).unwrap()["discarded"], 3); + assert!(recovery_ids(&mut s).is_empty()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn discard_failure_keeps_only_not_yet_deleted_candidates() { + let (mut s, d) = discard_fixture("discard-second-error"); + block_discard(&d.join("second")); + assert!(s.execute("file.discardRecovery", json!({"all": true})).is_err()); + assert_eq!(recovery_ids(&mut s), ["second", "third"]); + assert!(!d.join("first").exists()); + assert!(d.join("third/snapshot.fcproj").exists()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn discard_selected_failure_keeps_unselected_candidates() { + let (mut s, d) = discard_fixture("discard-selected-error"); + block_discard(&d.join("second")); + assert!(s.execute("file.discardRecovery", json!({"id": "second"})).is_err()); + assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]); + assert!(d.join("first/snapshot.fcproj").exists()); + assert!(d.join("third/snapshot.fcproj").exists()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn discard_default_failure_does_not_select_another_candidate() { + let (mut s, d) = discard_fixture("discard-default-error"); + block_discard(&d.join("first")); + let error = s.execute("file.discardRecovery", json!({})).unwrap_err().to_string(); + assert!(error.contains("first"), "{error}"); + assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]); + assert!(d.join("second/snapshot.fcproj").exists()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn discard_selection_and_unknown_id_preserve_other_candidates() { + let (mut s, d) = discard_fixture("discard-selection"); + assert!(s.execute("file.discardRecovery", json!({"id": "unknown"})).is_err()); + assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]); + assert_eq!(s.execute("file.discardRecovery", json!({"id": "second"})).unwrap()["discarded"], 1); + assert_eq!(recovery_ids(&mut s), ["first", "third"]); + assert!(!d.join("second").exists()); + assert_eq!(s.execute("file.discardRecovery", json!({})).unwrap()["discarded"], 1); + assert_eq!(recovery_ids(&mut s), ["third"]); + assert!(!d.join("first").exists()); + assert!(d.join("third/snapshot.fcproj").exists()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + #[test] fn preferences_get_set_and_persist() { let d = temp_dir("prefs"); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5c564e69e..e08003673 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1521,18 +1521,27 @@ fn build() -> Vec { let all = bool_p(p, "all").unwrap_or(false); let id = str_p(p, "id").map(str::to_string); let Some(per) = s.persistence.as_mut() else { return Ok(json!({"discarded": 0})) }; - let mut n = 0; + let mut n = 0usize; let mut keep = Vec::new(); + let mut failure = None; for (i, c) in std::mem::take(&mut per.candidates).into_iter().enumerate() { let hit = all || id.as_deref().map_or(i == 0, |want| want == c.id); - if hit { - crate::autosave::discard_candidate(&c).map_err(|e| EngineError::Other(e.to_string()))?; - n += 1; + if hit && failure.is_none() { + match crate::autosave::discard_candidate(&c) { + Ok(()) => n = n.saturating_add(1), + Err(e) => { + failure = Some(EngineError::Other(format!("could not discard recovery session {}: {e}", c.id))); + keep.push(c); + } + } } else { keep.push(c); } } per.candidates = keep; + if let Some(e) = failure { + return Err(e); + } if n == 0 { return Err(bad("file.discardRecovery", "no such recovery session")); } diff --git a/docs/project-files.md b/docs/project-files.md index 7878eadc5..2e2c380df 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -156,6 +156,8 @@ Premiere's auto-save can lose up to one interval of work. FilmCraft also keeps a Recover unsaved changes from 2026-10-01 00:02:09?"*, with the buttons **Recover**, **Not Now** and **Discard**. Stale directories without a snapshot are removed. Journals of running sessions are never touched. +- If discarding a recovery session fails, FilmCraft reports the error and keeps that session and + the remaining candidates in the recovery list for retry. Sessions already deleted stay removed. - Recovering loads the snapshot as an **unsaved** project with its original path, so Save writes over the original file. The recovered state goes into the new session's own journal before the old journal is deleted. From d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 08:16:09 -0600 Subject: [PATCH 100/110] EffectCraft #293: finish ProRes alpha runs (#2991) Write the required run after the final alpha difference, including a singleton. Add exact-codeword and strict slice conformance regressions; document native macOS and EffectCraft export validation. --- ROADMAP.md | 5 +- crates/prores/README.md | 31 ++++++++- crates/prores/src/encode.rs | 121 +++++++++++++++++++++++++++++++++++- docs/codec-parity.md | 5 +- 4 files changed, 155 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c77769f82..a93ae8ae0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ **Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (language table: German covers the whole interface) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration), @@ -107,6 +107,8 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g ## Progress log +- **2026-10-22:** ProRes 4444 / XQ alpha slices include the final singleton run code required by RDD 36:2022. Six HD fixtures rejected by native macOS decoding now pass; syntax regression tests cover final runs and slice widths ([details](crates/prores/README.md#alpha-bitstream-conformance), EffectCraft #293). + - **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088) - **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`). - **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)). @@ -166,6 +168,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | ProRes alpha: write the final singleton run code; 4444/XQ HD clips that failed native macOS decoding now decode, with syntax regression tests (EffectCraft #293) | | 2026-10-13 | minor | Language table: German covers the whole interface (~100%) | | 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) | | 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) | diff --git a/crates/prores/README.md b/crates/prores/README.md index 3659d1a0f..c852fa332 100644 --- a/crates/prores/README.md +++ b/crates/prores/README.md @@ -1,6 +1,6 @@ # filmcraft-prores -Clean-room Apple ProRes decoder and encoder in pure Rust, written from the public SMPTE RDD 36 +Clean-room Apple ProRes decoder and encoder in pure Rust, written from the public [SMPTE RDD 36:2022](https://pub.smpte.org/doc/rdd36/20220909-pub/rdd36-2022.pdf) bitstream description. Layer L0: depends only on `filmcraft-bitstream`, `thiserror` and (optional, feature `threads`, default on) `rayon`. Builds for `wasm32-unknown-unknown`; no `unsafe`. @@ -95,6 +95,35 @@ decodes them with `-xerror`, with no errors, and agrees with this decoder within 69.9/69.2 dB (Y/C) on mandelbrot at 100% of the target rate. Busy content lands at 99.9–100.4% of the target. +## Alpha bitstream conformance + +Every alpha difference is followed by a run code, including a final singleton +(RDD 36:2022 §5.3.3, Tables 12–14). Omitting the final run produces a truncated +alpha payload: FFmpeg may accept it, while macOS native HD decoding fails with AVFoundation `-11821` / underlying `-12137`. This affected +both 4444 and 4444 XQ exports, including EffectCraft's alpha output +([effectcraft#293](https://github.com/storytold/effectcraft/issues/293)). + +The encoder tests check exact singleton codewords at 8 and 16 bits, run-length +boundaries through 2048 and split runs, final-value bit alignments, and complete +encoded slices for both profiles and all four slice widths. A separate, +bounds-checked parser follows the published syntax and requires every run and +zero alignment bit; round-tripping through a permissive decoder alone cannot +catch this defect. + +Native validation on Apple M5 / macOS 27.0.1 used 14 synthetic 1920×1080 clips: +opaque, transparent, gradient, changing last pixel, moving block, noisy alpha, +and RGB-only, for each 4444 profile. Before the fix, the gradient, last-pixel and +noise cases failed in AVAssetReader (six clips); afterwards all 14 decoded all +six frames. FFmpeg's decoded frame hashes are unchanged by the fix. Small +128×32 clips passed even before the fix. Resolve itself and the reporter's +M1 Pro / macOS 15.8 environment were not available for this check. + +EffectCraft 0.16.0's complete export pipeline was also tested using an animated +synthetic PNG with varying alpha: 4444 / XQ × straight / premultiplied × motion +blur off / on (eight six-frame HD movies). All eight failed native decoding with +the pinned encoder, and all eight passed with this codec patched in. FFmpeg's +decoded frame hashes remained identical for every before/after pair. + ## Performance Measured with `cargo test --release -p filmcraft-prores --test it perf:: -- --ignored --nocapture` on diff --git a/crates/prores/src/encode.rs b/crates/prores/src/encode.rs index 06d8cb655..24c925393 100644 --- a/crates/prores/src/encode.rs +++ b/crates/prores/src/encode.rs @@ -472,9 +472,9 @@ pub(crate) fn encode_alpha(w: &mut Writer, vals: &[u32], bits: u32) { } prev = v; idx += 1; - if idx >= n { - break; - } + // RDD 36:2022 §5.3.3 requires a run after every alpha difference, + // including a singleton at the end of the slice. Do not replace that + // final `1` with byte-alignment zeros: native decoders reject it. let mut run = 0; while idx + run < n && vals[idx + run] == prev && run < 2047 { run += 1; @@ -493,3 +493,118 @@ pub(crate) fn encode_alpha(w: &mut Writer, vals: &[u32], bits: u32) { idx += run; } } + +#[cfg(test)] +mod alpha_tests { + use super::*; + + #[test] + fn alpha_final_singleton_has_a_run_code() { + // RDD 36:2022 §5.3.3: every difference is followed by a run, including + // the final one. Tables 13/14 encode -1; Table 12 encodes run 1 as `1`. + // With 16-bit alpha the difference occupies exactly one byte, so the + // required final run must start another byte, followed by zero padding. + for (bits, expected) in [(16, vec![0x01, 0x80]), (8, vec![0x0c])] { + let mut w = Writer::new(); + encode_alpha(&mut w, &[(1 << bits) - 2], bits); + assert_eq!(w.finish(), expected, "{bits}-bit final singleton"); + } + } +} + +#[cfg(test)] +mod alpha_conformance_tests { + use super::*; + use filmcraft_bitstream::BitReader; + + /// RDD 36:2022 §5.3.3 and Tables 12–14, using a bounds-checked reader. + /// Unlike the production decoder's zero-padded hot path, this checks every + /// final run and all alignment bits, so a truncated run cannot be accepted. + fn read_alpha(data: &[u8], bits: u32, count: usize) -> Vec { + let mut r = BitReader::new(data); + let mask = (1u32 << bits) - 1; + let mut prev = mask; + let mut out = Vec::new(); + while out.len() < count { + let diff = if r.read_bits(1).unwrap() == 1 { + r.read_bits(bits).unwrap() + } else { + let code = r.read_bits(if bits == 16 { 7 } else { 4 }).unwrap(); + let magnitude = (code >> 1) + 1; + if code & 1 == 0 { magnitude } else { magnitude.wrapping_neg() } + }; + prev = prev.wrapping_add(diff) & mask; + let run = if r.read_bits(1).unwrap() == 1 { + 1 + } else { + let short = r.read_bits(4).unwrap(); + if short == 0 { r.read_bits(11).unwrap() + 1 } else { short + 1 } + } as usize; + assert!(run <= count - out.len(), "alpha run exceeds the slice"); + out.extend(std::iter::repeat_n(prev, run)); + } + assert!(r.bits_left() < 8, "only byte-alignment padding may remain"); + while r.bits_left() > 0 { + assert_eq!(r.read_bits(1).unwrap(), 0, "alignment padding must be zero"); + } + out + } + + #[test] + fn alpha_runs_cover_boundaries_and_final_values() { + for bits in [8, 16] { + let mask = (1 << bits) - 1; + for run in [1, 2, 15, 16, 17, 2047, 2048, 2049, 4096] { + for tail in 0..=8 { + let mut values = vec![mask - 1; run]; + // Short negative/positive deltas, modulo transitions and + // escaped full-width deltas, ending on every bit alignment. + values.extend([0, mask, 1, 7, 8, 64, 65, mask / 2].into_iter().take(tail)); + let mut w = Writer::new(); + encode_alpha(&mut w, &values, bits); + assert_eq!(read_alpha(&w.finish(), bits, values.len()), values, "bits={bits}, run={run}, tail={tail}"); + } + } + } + } + + #[test] + fn encoded_alpha_slices_have_complete_final_runs() { + for profile in [Profile::P4444, Profile::P4444Xq] { + for log2_slice_mbs in 0..=3 { + let (width, height) = (128, 32); + let mut frame = Frame::new(width, height, ChromaFormat::Yuv444, 16, true); + frame.y.fill(32768); + frame.cb.fill(32768); + frame.cr.fill(32768); + let alpha = frame.alpha.as_mut().unwrap(); + for (i, a) in alpha.iter_mut().enumerate() { + *a = (i as u16).wrapping_mul(257); + } + let mut cfg = EncoderConfig::new(profile, width, height); + cfg.log2_slice_mbs = log2_slice_mbs; + let bytes = Encoder::with_config(cfg).encode(&frame).unwrap(); + let (_, start, _) = FrameHeader::parse(&bytes).unwrap(); + let picture = &bytes[start..]; + let header = PictureHeader::parse(picture).unwrap(); + let table = &picture[header.header_size..][..2 * header.num_slices]; + let mut offset = header.header_size + table.len(); + let sw = 16 << log2_slice_mbs; + for (i, size) in table.as_chunks::<2>().0.iter().enumerate() { + let size = u16::from_be_bytes([size[0], size[1]]) as usize; + let slice = &picture[offset..offset + size]; + offset += size; + let color_bytes: usize = slice[2..8].as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes([b[0], b[1]]) as usize).sum(); + let payload = &slice[(slice[0] >> 3) as usize + color_bytes..]; + let actual = read_alpha(payload, 16, sw * 16); + let x0 = i % (width as usize / sw) * sw; + let y0 = i / (width as usize / sw) * 16; + let alpha = frame.alpha.as_ref().unwrap(); + let expected: Vec = (y0..y0 + 16).flat_map(|y| (x0..x0 + sw).map(move |x| alpha[y * width as usize + x] as u32)).collect(); + assert_eq!(actual, expected, "{profile:?}, slice {i}, log2={log2_slice_mbs}"); + } + assert_eq!(offset, picture.len()); + } + } + } +} diff --git a/docs/codec-parity.md b/docs/codec-parity.md index f1ae5245c..d3a79679d 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -1,6 +1,6 @@ # Codec parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: every codec Premiere Pro 26.5.2 decodes or encodes, against ours) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 Video and audio codecs, decode and encode, with fidelity and how each is tested. Containers, project and interchange files are in [file-format-parity.md](file-format-parity.md); hardware @@ -17,7 +17,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed |---|---|---|---|---|---|---| | H.264 / AVC | decode all common profiles incl. High 10, 4:2:2 (XAVC S-I, AVC-Intra); encode 8/10-bit | own decoder, **8-bit 4:2:0 only** (Baseline / Main / High, CABAC / CAVLC); no High 10 / 4:2:2 / 4:4:4, interlaced field / MBAFF, lossless | own encoder (High / Main / Baseline, B-frames, CBR / VBR / 2-pass); 8-bit 4:2:0 only; hardware VideoToolbox / NVENC | decode bit-exact on 37+ conformance streams, 500–600 fps 1080p; encode ffprobe-checked, byte-identical across machines | 65% | 20–35 h | | HEVC / H.265 | decode Main, Main 10, 4:2:2 10 (XAVC HS); encode 8/10-bit, HDR | own decoder Main / Main 10 / Main Still Picture, bit-exact on 41 fixtures, ~225 fps 1080p; **no 4:2:2 / RExt** | **hardware only** (VideoToolbox 8-bit; NVENC 8-bit and Main 10 HDR on Windows); no software encoder | NVENC round trip 48.9 dB (8-bit), 71.1 dB (10-bit); ffprobe | 60% | 30–60 h | -| ProRes 422 family / 4444 / XQ | decode + encode all | own decode + encode, all six profiles, alpha | yes (export UI exposes 422 flavours; 4444 / XQ request #342) | ±1 LSB, 318 fps | 85% | 3–6 h | +| ProRes 422 family / 4444 / XQ | decode + encode all | own decode + encode, all six profiles, alpha | yes (export UI exposes 422 flavours; 4444 / XQ request #342) | ±1 LSB, 318 fps; alpha final-run conformance and native macOS HD decode checked ([details](../crates/prores/README.md#alpha-bitstream-conformance)) | 85% | 3–6 h | | ProRes RAW | decode | none (#345) | — | — | 0% | 30–50 h (spec availability uncertain) | | DNxHD / DNxHR | decode + encode, MXF / MOV | own decode (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2 / 4:4:4, interlaced, alpha) and encode LB / SQ / HQ / HQX / 444 | yes, MOV + MXF | within IDCT precision of ffmpeg on 22 fixtures | 90% | 2–4 h | | AV1 | decode (hardware on Windows); export through Media Encoder plug-ins / recent versions | own decoder Main 8/10-bit, all tools, film grain, bit-exact vs libdav1d on 41 vectors; hardware on Windows | none | slow in software (~4 fps 1080p single-thread) | 55% | 30–50 h (encoder + speed) | @@ -61,5 +61,6 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | ProRes alpha final-run syntax regression tests and native macOS HD decode validation | | 2026-10-11 | minor | CinemaDNG / DNG decode (#1065) | | 2026-10-10 | major | Created: codec-by-codec comparison with Premiere 26.5.2 from the codec crates' READMEs, the export format registry and the installed bundle's listings | From e7f7775943a8f52b7b8b103af6c33fb05c0723b5 Mon Sep 17 00:00:00 2001 From: Michael Butler Date: Sat, 24 Oct 2026 10:16:14 -0400 Subject: [PATCH 101/110] control: preserve MCP pointer input defaults (#2784) (#3004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * control: preserve MCP pointer input defaults (#2784) The MCP bridge emits null for omitted click counts and drag steps. Treat null like an absent value in bounded_repetitions, preserving the bounds introduced by #2868 and all other invalid-input errors. Add six regression tests and document the current limits and defaults. Validation: both null-default tests failed against upstream before the fix; 205 UI unit tests and seven UI integration tests now pass. Formatting, layers, assets and browser WASM pass. Full CI stops at an unrelated upstream Clippy diagnostic in crates/prores/src/decode.rs:133. Prepared as a non-draft PR at the contributor’s explicit request. Co-authored-by: Codex * docs: explain pointer input defaults and regression coverage (#2784) Document the bounded repetition helper, MCP pointer handlers and regression tests in response to CodeRabbit docstring coverage feedback. No executable behavior changes. Validation: formatting and Rustdoc generation pass. Full CI still stops at the unchanged upstream ProRes nonminimal_bool Clippy diagnostic in crates/prores/src/decode.rs:133. Co-authored-by: Codex --------- Co-authored-by: Codex --- crates/automation/src/server.rs | 5 +- crates/ui-egui/README.md | 10 +++ crates/ui-egui/src/control.rs | 116 ++++++++++++++++++++++++- crates/ui-egui/tests/it/keyboard_ui.rs | 26 ++++++ docs/control-protocol.md | 8 +- 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index b2e313504..33f5dc653 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -108,7 +108,7 @@ pub struct ClickParams { /// left | right | middle #[serde(default)] pub button: Option, - /// 2 for double-click. + /// Number of clicks (0–10; defaults to 1). Use 2 for double-click. Larger values are rejected. #[serde(default)] pub count: Option, /// {"shift":bool,"alt":bool,"command":bool,"ctrl":bool} @@ -122,6 +122,7 @@ pub struct DragParams { pub from: Value, /// End point, same forms. pub to: Value, + /// Movement steps (0–1024; defaults to 12; 0 or 1 uses 2). Larger values are rejected. #[serde(default)] pub steps: Option, #[serde(default)] @@ -497,6 +498,7 @@ impl FilmcraftMcp { self.ui("ui.elements", json!({"prefix": p.prefix.unwrap_or_default()})).await } + /// Forward a click to the live editor; an omitted count is sent as null and uses its default. #[tool( title = "Live app: click", description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.", @@ -513,6 +515,7 @@ impl FilmcraftMcp { self.ui("ui.click", v).await } + /// Forward a drag to the live editor, which applies the step default and validates its bounds. #[tool( title = "Live app: drag", description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks).", diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 75fbe94e1..81060d656 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -3,6 +3,16 @@ The egui frontend draws panels and dialogs, and dispatches project changes through the engine. Interactive controls keep stable automation ids regardless of the interface language. +## Control input limits + +The control channel bounds `ui.click` to 10 clicks and `ui.drag` to 1024 movement steps (#2784). +The MCP bridge sends null for omitted counts/steps, so the validator treats null like an absent +field: one click or 12 drag steps. Negative, fractional, non-numeric and over-limit values still +return errors before queuing any input. Zero clicks emit no button events; 0–1 drag steps use 2. +`control::input_bounds_tests` covers defaults, rejection, unchanged pending input and valid +gestures; `keyboard_ui` verifies that rejected requests leave the UI usable. See the +[control protocol](../../docs/control-protocol.md). + ## Transition drop previews Dragging a video or audio transition over a compatible clip shows its actual time span, the diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 960fa8915..0fe8fb6aa 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -28,8 +28,15 @@ use serde_json::{Value, json}; const MAX_CLICK_COUNT: u64 = 10; const MAX_DRAG_STEPS: u64 = 1_024; +/// Read a pointer repetition count, using `default` when `key` is absent or null. +/// +/// Reject non-integer, negative and above-`maximum` values; raise accepted counts to `minimum`. fn bounded_repetitions(params: &Value, key: &str, default: u64, minimum: u64, maximum: u64) -> Result { - let value = params.get(key).map(|value| value.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))).transpose()?.unwrap_or(default); + // The MCP bridge serializes omitted optional counts as null. + let value = match params.get(key) { + None | Some(Value::Null) => default, + Some(value) => value.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))?, + }; if value > maximum { return Err(format!("`{key}` must not exceed {maximum}")); } @@ -642,6 +649,113 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result, String> { Ok(out) } +#[cfg(test)] +mod input_bounds_tests { + use super::*; + + /// Create an empty editor session for inspecting queued synthetic input without a window. + fn app() -> FilmcraftApp { + FilmcraftApp::new(filmcraft_engine::Session::default()) + } + + /// Dispatch a control request and fail the test if hostile parameters cause a panic. + fn request(app: &mut FilmcraftApp, method: &str, params: Value) -> Outcome { + let (req, _) = ControlRequest::new(method, params); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handle(app, &egui::Context::default(), &req))) + .expect("hostile control parameters must not panic") + } + + /// Require an immediate error naming `field`, with all previously queued input unchanged. + fn assert_rejected(app: &mut FilmcraftApp, method: &str, params: Value, field: &str) { + let before = app.synthetic.clone(); + let Outcome::Done(reply) = request(app, method, params) else { panic!("invalid input must return an error immediately") }; + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap().contains(field), "{reply}"); + assert_eq!(app.synthetic, before, "rejection must not queue partial input or change existing input"); + } + + /// Check the first rejected click count without allocating an enormous event queue. + #[test] + fn click_count_above_limit_is_rejected() { + // A small boundary value reproduces the missing guard without exhausting memory. + assert_rejected(&mut app(), "ui.click", json!({"x": 10, "y": 20, "count": 11}), "count"); + } + + /// Check that one step above the drag limit returns an error before queuing input. + #[test] + fn drag_steps_above_limit_are_rejected() { + assert_rejected(&mut app(), "ui.drag", json!({"from": {"x": 0, "y": 0}, "to": {"x": 10, "y": 20}, "steps": 1025}), "steps"); + } + + /// Reject extreme counts and malformed types while preserving pending and subsequent input. + #[test] + fn hostile_counts_and_steps_leave_pending_input_unchanged() { + let mut app = app(); + app.synthetic.push(egui::Event::PointerGone); + for value in [json!(u32::MAX), json!(u64::MAX), json!(-1), json!(1.5), json!("2"), json!(true), json!([]), json!({})] { + assert_rejected(&mut app, "ui.click", json!({"x": 10, "y": 20, "count": value}), "count"); + assert_rejected(&mut app, "ui.drag", json!({"from": {"x": 0, "y": 0}, "to": {"x": 10, "y": 20}, "steps": value}), "steps"); + } + // A rejected request must not prevent subsequent valid input. + assert!(matches!(request(&mut app, "ui.click", json!({"x": 10, "y": 20})), Outcome::AfterInput)); + } + + /// Verify missing/null defaults, accepted click bounds, button pairs and modifier resets. + #[test] + fn valid_click_counts_preserve_button_pairs_and_modifiers() { + let mut app = app(); + let pos = egui::pos2(10.0, 20.0); + let modifiers = egui::Modifiers { shift: true, ..Default::default() }; + for (value, count) in [(None, 1), (Some(Value::Null), 1), (Some(json!(0)), 0), (Some(json!(1)), 1), (Some(json!(2)), 2), (Some(json!(10)), 10)] { + app.synthetic.clear(); + let mut params = json!({"x": 10, "y": 20, "button": "right", "modifiers": {"shift": true}}); + if let Some(v) = value { + params["count"] = v; + } + assert!(matches!(request(&mut app, "ui.click", params), Outcome::AfterInput)); + let mut expected = vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(pos)]; + for _ in 0..count { + for pressed in [true, false] { + expected.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers }); + } + } + expected.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + assert_eq!(app.synthetic, expected); + } + app.synthetic.clear(); + assert!(matches!(request(&mut app, "ui.move", json!({"x": 10, "y": 20, "count": u64::MAX})), Outcome::AfterInput)); + assert_eq!(app.synthetic.len(), 3, "ui.move does not use count"); + } + + /// Verify missing/null defaults and accepted drag bounds retain endpoints and button release. + #[test] + fn valid_drag_steps_preserve_defaults_endpoints_and_release() { + let mut app = app(); + for (value, steps) in [(None, 12), (Some(Value::Null), 12), (Some(json!(0)), 2), (Some(json!(1)), 2), (Some(json!(2)), 2), (Some(json!(1024)), 1024)] { + app.synthetic.clear(); + let mut params = json!({"from": {"x": 0, "y": 0}, "to": {"x": 10, "y": 20}, "modifiers": {"shift": true}}); + if let Some(v) = value { + params["steps"] = v; + } + assert!(matches!(request(&mut app, "ui.drag", params), Outcome::AfterInput)); + assert_eq!(app.synthetic.len(), steps + 5); + let modifiers = egui::Modifiers { shift: true, ..Default::default() }; + assert_eq!(app.synthetic[0], egui::Event::ModifiersChanged(modifiers)); + assert_eq!(app.synthetic[1], egui::Event::PointerMoved(egui::pos2(0.0, 0.0))); + assert_eq!( + app.synthetic[2], + egui::Event::PointerButton { pos: egui::pos2(0.0, 0.0), button: egui::PointerButton::Primary, pressed: true, modifiers } + ); + assert_eq!(app.synthetic[steps + 2], egui::Event::PointerMoved(egui::pos2(10.0, 20.0))); + assert_eq!( + app.synthetic[steps + 3], + egui::Event::PointerButton { pos: egui::pos2(10.0, 20.0), button: egui::PointerButton::Primary, pressed: false, modifiers } + ); + assert_eq!(app.synthetic[steps + 4], egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + } +} + #[cfg(test)] mod base64_tests { use serde_json::json; diff --git a/crates/ui-egui/tests/it/keyboard_ui.rs b/crates/ui-egui/tests/it/keyboard_ui.rs index b1ea139a2..e0147f1d1 100644 --- a/crates/ui-egui/tests/it/keyboard_ui.rs +++ b/crates/ui-egui/tests/it/keyboard_ui.rs @@ -71,6 +71,32 @@ impl Driver { } } +/// Rejected pointer counts must preserve the editor and allow later MCP-default and double clicks. +#[test] +fn rejected_pointer_counts_leave_the_ui_usable() { + let mut d = Driver::new(); + let project = d.app().session.project.clone(); + for (method, params) in [ + ("ui.click", json!({"id": "tools.Razor", "count": u64::MAX})), + ("ui.drag", json!({"from": {"id": "tools.Razor"}, "to": {"id": "tools.Selection"}, "steps": u64::MAX})), + ] { + let (req, reply) = ControlRequest::new(method, params); + d.tx.send(req).unwrap(); + d.frames(3); + let response = reply.try_recv().expect("invalid counts must be answered without queuing input"); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(d.app().ui.tool, filmcraft_ui_egui::state::Tool::Selection); + assert_eq!(*d.app().session.project, *project); + } + d.ok("ui.click", json!({"id": "tools.Razor", "count": null})); + d.frames(3); + assert_eq!(d.app().ui.tool, filmcraft_ui_egui::state::Tool::Razor); + d.ok("ui.click", json!({"id": "tools.Selection", "count": 2})); + d.frames(3); + assert_eq!(d.app().ui.tool, filmcraft_ui_egui::state::Tool::Selection); + assert_eq!(*d.app().session.project, *project); +} + /// Ids added for keyboard parity (engine + UI). fn new_ids() -> Vec { let mut v: Vec = filmcraft_engine::keyboard::commands().iter().map(|c| c.id.to_string()).collect(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6093c4fbd..f5b32a1d9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,8 +52,8 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect | | `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}, keyframeSelection:[…]}` | `keyframeSelection` replaces the Effect Controls keyframe selection (`[{clip, effect, param, mask?, time}]`, `time` in media ticks; read back as `ui.inspect` → `ui.keyframe_selection`; a click on a lane keyframe selects just that one); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` `export` deep-merges into Export mode state (`preset` — applied first —, `settings` (ExportSettings JSON), `fileName`, `location`, `range`, `customStart`, `customEnd`, `openSections`, `manager` (Preset Manager: `query`, `favoritesOnly`, `selected`, `saveName`), `quickOpen`, `quickPath`, `quickPreset`); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `projectPanel` merges into the Project panel's view state (`bin` shown in place, `tabs` (bins in a tab or window: `{bin, floating, view, iconSize, nav}`), `activeTab`, `selectedBin`, `rename: {item|bin, text}`, `dialog` (`{"metadataDisplay": {columns, filter}}`, `{"savePresetAs": {name}}`, `{"managePresets": {selected, name}}`, `{"freeformOptions": {options}}`, `{"saveArrangement": {name, bin}}`)); `mediaBrowser` into the Media Browser's (`expanded`, `pathEdit`, `editColumns`, `treeWidth`). View settings, columns and presets are engine commands (`project.view.set`, `project.columns.set`, `project.viewPreset.*`, `mediaBrowser.settings`) | | `ui.panel.show` / `ui.panel.close` | `{panel}` | | -| `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input | -| `ui.drag` | `{from, to, steps, modifiers}` | press–move–release | +| `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input; click `count` is an integer 0–10 (default 1); `ui.move` ignores it | +| `ui.drag` | `{from, to, steps, modifiers}` | press–move–release; `steps` is an integer 0–1024 (default 12; 0 or 1 uses 2 steps) | | `ui.scroll` | `{id|x,y, dx, dy, modifiers}` | wheel / trackpad | | `ui.key` / `ui.type` | `{key}` (`Cmd+K`, `Space`…; off macOS `Ctrl+K` is the same key) / `{text}` | keyboard | | `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null | @@ -63,6 +63,10 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.focus` | `{}` / `{id}` | empty parameters activate the native window; a widget ID requests egui focus without OS activation | | `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle buffers of the frame pool, the planes of evicted frames and the compositor's float images, which a standalone export frees when it ends; and buffers taken from it instead of allocated), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `monitor`: the `frame` and `revision` of the picture the Program monitor shows against `playheadFrame` and `projectRevision` (how far a drag or a scrub is ahead of the picture); `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | +For `ui.click` and `ui.drag`, omitted or null counts/steps use the defaults. Out-of-range or +non-integer values return `{"ok": false, "error": "…"}` before queuing any pointer input. +These limits also apply to the MCP bridge's `ui_click`, `ui_drag` and `ui_control` tools. + **Timeline thumbnails.** `ui.set {timeline: {thumbnails}}` sets the Timeline wrench menu's Show Video Thumbnails: `off`, `head`, `headAndTail` or `continuous`. From c5da984f6b092ad12b25e5219b41a4fa63589a81 Mon Sep 17 00:00:00 2001 From: Tex Jernigan Date: Sat, 24 Oct 2026 10:16:29 -0400 Subject: [PATCH 102/110] meters: drop to silence right after Stop (#3011) After Stop the meters kept the playback ballistics (20 dB/s bars, 6 dB/s peak holds), so a -23 dB peak took ~11 s to clear and the meters looked like audio was still playing. Once nothing is live, bars and peak holds now fall at 360 dB/s and reach the floor within ~0.25 s. Playback, recording and scrubbing ballistics are unchanged; the no-input repaint from #1133 (#1062) still drives the release. Co-authored-by: Brandon Thomas --- crates/ui-egui/src/panels/mixer.rs | 13 ++++++++----- crates/ui-egui/tests/it/mixer_ui.rs | 7 +++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index 1d5046b38..aea3b830e 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -87,7 +87,7 @@ pub const INPUT_METER: u64 = u64::MAX - 1; /// Per-strip meter state, `strip id → [level, peak]` per channel (dBFS; 2 channels, or 6 for 5.1 /// strips), fed by the playing mix ([`filmcraft_render::mixer::LiveMix`]) with fast attack and a -/// 20 dB/s fall. [`INPUT_METER`] holds the voice-over input. +/// 20 dB/s fall (peak holds 6 dB/s) while live, dropping within a quarter second after Stop. [`INPUT_METER`] holds the voice-over input. pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap> { let id = egui::Id::new("mixer-meters"); let frame = ui.ctx().cumulative_frame_nr(); @@ -102,15 +102,18 @@ pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap>) = + d.harness.ctx.data(|data| data.get_temp(egui::Id::new("mixer-meters"))).unwrap(); + assert!(meters.values().flatten().all(|meter| *meter == [-90.0; 2]), "{workspace}: still falling 0.4 s after Stop: {meters:?}"); // Harness::run follows immediate repaint requests, adding no mouse or keyboard input. let steps = d.harness.run(); // Inspect both values that drive the bars: a silent level alone must not strand its From a5c2a73cbd3c876838fd7ed5edc000349a94699e Mon Sep 17 00:00:00 2001 From: Tex Jernigan Date: Sat, 24 Oct 2026 10:16:33 -0400 Subject: [PATCH 103/110] Timeline: the Razor tool cuts captions (#3039) * Timeline: the Razor tool cuts captions With the Razor tool (C), a click on a caption block in the Timeline splits it there, snapped like a clip cut, with the same white preview line; Shift cuts every caption on an unlocked caption track at that time. Before, the caption blocks took every click for select/move whatever the tool, so the Razor did nothing on them. Locked caption tracks ignore it. Co-Authored-By: Claude Opus 5.5 * Move the caption Razor UI test into the shared ui-egui test binary Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/panels/timeline.rs | 2 +- .../ui-egui/src/panels/timeline_captions.rs | 49 +++++++- crates/ui-egui/tests/it/caption_razor_ui.rs | 116 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/captions.md | 6 +- 5 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/it/caption_razor_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 33c243762..5e9c666b8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1775,7 +1775,7 @@ fn vertical_scrollbar(ui: &mut egui::Ui, bar: Rect, value: &mut f32, max: f32, i } /// Snap `t` to nearby candidates (edits, playhead, markers, in/out). Returns snapped tick. -fn snap(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, t: Tick, exclude: &[ClipId]) -> Tick { +pub(super) fn snap(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, t: Tick, exclude: &[ClipId]) -> Tick { snap_to(app, seq, layout, t, exclude, true) } diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index 6b93456ae..75cf0e71a 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -1,7 +1,8 @@ //! Caption track rows of the Timeline: drawn in their own area above the video tracks (Premiere's //! layout). Each row has a header (C1…, name, format, output eye, lock) and caption blocks in a //! distinct colour showing their text. Click selects (Cmd/Shift adds), dragging the body moves, -//! dragging an edge trims, double-click opens the Text panel's Captions tab. Every gesture ends in +//! dragging an edge trims, the Razor tool splits (Shift: every caption track), double-click opens +//! the Text panel's Captions tab. Every gesture ends in //! one `captions.*` command; every element registers an automation id (`timeline.caption.*`, //! `timeline.captionTrack.C1.*`). @@ -14,6 +15,7 @@ use super::timeline::Layout; use crate::FilmcraftApp; use crate::dock::PanelKind; use crate::icons::Icon; +use crate::state::Tool; use crate::theme::Tokens; /// Height of one caption track row. @@ -182,12 +184,55 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: if r.width() <= 0.0 { continue; } - interact_block(app, ui, tr, c, r, rate, &mut actions); + if app.ui.tool == Tool::Razor { + razor_block(app, ui, seq, layout, tr, c, r, row, &mut actions); + } else { + interact_block(app, ui, tr, c, r, rate, &mut actions); + } } } run(app, ui, actions); } +/// The Razor tool over a caption: a white line where it would cut, and a click splits the caption +/// there (Shift: every caption on an unlocked caption track at that time), as it cuts clips. +#[allow(clippy::too_many_arguments)] +fn razor_block( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + seq: &Sequence, + layout: &Layout, + tr: &CaptionTrack, + c: &filmcraft_project::Caption, + r: Rect, + row: Rect, + actions: &mut Vec<(String, Value)>, +) { + let ctx = ui.ctx().clone(); + let resp = ui.interact(r, egui::Id::new(("caption-block", c.id.0)), Sense::click_and_drag()); + if tr.locked { + return; + } + let rate = seq.settings.frame_rate; + let at = |app: &mut FilmcraftApp, x: f32| super::timeline::snap(app, seq, layout, rate.snap_nearest(layout.tick_at(x)), &[]); + if let Some(p) = resp.hover_pos() { + ctx.set_cursor_icon(CursorIcon::Crosshair); + let x = layout.x_of(at(app, p.x)); + ui.painter().with_clip_rect(layout.content).line_segment([pos2(x, row.min.y), pos2(x, row.max.y)], Stroke::new(1.0, Color32::WHITE)); + } + // a press that starts a drag cuts as well, as on clips + if (resp.clicked() || resp.drag_started()) + && let Some(p) = ctx.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let t = at(app, p.x); + if ctx.input(|i| i.modifiers.shift) { + actions.push(("captions.split".into(), json!({"time": t.0}))); + } else if c.start < t && t < c.end() { + actions.push(("captions.split".into(), json!({"caption": c.id.0, "time": t.0}))); + } + } +} + fn interact_block( app: &mut FilmcraftApp, ui: &mut egui::Ui, diff --git a/crates/ui-egui/tests/it/caption_razor_ui.rs b/crates/ui-egui/tests/it/caption_razor_ui.rs new file mode 100644 index 000000000..114c68d25 --- /dev/null +++ b/crates/ui-egui/tests/it/caption_razor_ui.rs @@ -0,0 +1,116 @@ +//! The Razor tool (C) on a caption in the Timeline cuts it, as it cuts clips. The real +//! `FilmcraftApp` under `egui_kittest` with the demo project. + +use std::sync::mpsc::{Sender, channel}; + +use egui::{Modifiers, Pos2, Rect, pos2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// The demo project with snapping off, so drags land where the pointer goes; frames 1/60 s + /// apart, as on a display. + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("sequence.snap", json!({"on": false})).expect("snapping off"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d.ok("ui.elements", json!({"prefix": "timeline.clip."})); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).expect("control channel"); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn rect(&mut self, id: &str) -> Option { + self.frames(2); + let els = self.ok("ui.elements", json!({"prefix": id})); + let el = els.as_array()?.iter().find(|e| e["id"] == id)?.clone(); + let r: Vec = el["rect"].as_array()?.iter().filter_map(|v| v.as_f64().map(|v| v as f32)).collect(); + Some(Rect::from_min_size(pos2(*r.first()?, *r.get(1)?), egui::vec2(*r.get(2)?, *r.get(3)?))) + } + + /// (start, end, text) of every caption on C1. + fn captions(&mut self) -> Vec<(i64, i64, String)> { + let r = self.exec("captions.list", json!({})); + r["tracks"][0]["captions"] + .as_array() + .expect("captions") + .iter() + .map(|c| (c["start"].as_i64().expect("start"), c["end"].as_i64().expect("end"), c["text"].as_str().expect("text").to_string())) + .collect() + } + + /// Press at `from`, move to `to` over `steps` frames with `mods` held, release. + fn drag_with(&mut self, from: Pos2, to: Pos2, steps: usize, mods: Modifiers) { + let push = |d: &mut Self, e: egui::Event| d.harness.input_mut().events.push(e); + push(self, egui::Event::ModifiersChanged(mods)); + push(self, egui::Event::PointerMoved(from)); + self.frames(1); + push(self, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: mods }); + self.frames(1); + for i in 1..=steps { + push(self, egui::Event::PointerMoved(from + (to - from) * (i as f32 / steps as f32))); + self.frames(1); + } + push(self, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: mods }); + self.frames(2); + push(self, egui::Event::ModifiersChanged(Modifiers::NONE)); + self.frames(1); + } + + fn click(&mut self, at: Pos2) { + self.drag_with(at, at, 0, Modifiers::NONE); + } +} + +/// With the Razor tool (C), a click on a caption in the Timeline cuts it in two there. The +/// caption blocks took the click for selecting and moving whatever the tool, so it did nothing. +#[test] +fn razor_tool_splits_a_caption() { + let mut d = Driver::demo(); + let id = d.exec("captions.add", json!({"seconds": 2.0, "durationSeconds": 4.0, "text": "cut me"}))["caption"].as_u64().expect("id"); + d.ok("ui.set", json!({"tool": "razor"})); + let r = d.rect(&format!("timeline.caption.{id}")).expect("caption block"); + d.click(r.center()); + let caps = d.captions(); + assert_eq!(caps.len(), 2, "the caption was cut: {caps:?}"); + assert_eq!(caps[0].1, caps[1].0, "the halves meet at the cut"); + assert!(caps.iter().all(|c| c.2 == "cut me")); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index c5a3437d2..d585570c7 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,6 +1,7 @@ mod audio51_ui; mod audio_fx_editor_ui; mod caption_properties_ui; +mod caption_razor_ui; mod captions_font_ui; mod chrome_ui; mod clip_audio_ui; diff --git a/docs/captions.md b/docs/captions.md index 871427d48..d54cc4dac 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -40,7 +40,7 @@ Details of each format and the SCC encoder are in [`crates/captions/README.md`]( | `captions.add` | Sequence ▸ Captions ▸ Add Caption at Playhead (⌥⌘C) | `track`, `text`, `time`, `durationSeconds` (3) | | `captions.next` / `captions.previous` | Sequence ▸ Captions ▸ Go to Next/Previous Caption Segment (⌥⌘↓/↑) | | | `captions.showAll` / `captions.hideAll` | Sequence ▸ Captions | | -| `captions.split` | Text panel ✂ | `caption`, `time` (default: every caption under the playhead) | +| `captions.split` | Text panel ✂; Razor tool on a caption (⇧: every caption track) | `caption`, `time` (default: every caption under the playhead) | | `captions.merge` | Text panel | `captions` (consecutive, one track) | | `captions.setText` | Text panel text field | `caption`, `text`, `speaker` | | `captions.setTimes` | Text panel in/out fields | `caption`, `start…`/`end…` (`Time` ticks, `Frame`, `Seconds`, `Timecode`) | @@ -62,8 +62,8 @@ also splits captions. ## Timeline and Text panel - Caption rows: header with `C1…` badge, lock, output eye, name and format; caption blocks in - violet showing their text (Arabic and Hebrew in reading order from the block's right edge). Click selects (⌘/⇧ adds), drag moves, edge drag trims, double-click - opens the Text panel. Automation ids: `timeline.caption.`, + violet showing their text (Arabic and Hebrew in reading order from the block's right edge). Click selects (⌘/⇧ adds), drag moves, edge drag trims, the Razor tool cuts, + double-click opens the Text panel. Automation ids: `timeline.caption.`, `timeline.captionTrack.C1[.locked|.enabled]`. - **Text panel ▸ Captions**: search, track picker (also creates tracks of each format), add / split / merge / delete / export buttons, the list of segments with editable in/out timecodes From 964424ea8dfbdc73a0972190be3ca9f2c3dee4cb Mon Sep 17 00:00:00 2001 From: Tex Jernigan Date: Sat, 24 Oct 2026 10:17:02 -0400 Subject: [PATCH 104/110] Timeline: Option-drag duplicates captions (#3040) * Timeline: Option-drag duplicates captions Option-dragging a caption block leaves the original in place and drops a copy where it is released; the cursor turns into a copy cursor and the original stays drawn under the copy while dragging. A drag now carries the whole caption selection, not only the grabbed block. New command `captions.duplicate` {captions, delta|deltaFrames}: copies on their own tracks, never before 0, selected afterwards, one undo step. Captions never overlap, so `edit::captions::overwrite_captions` places copies over what is there: captions under a copy are trimmed, split around it or removed. Co-Authored-By: Claude Opus 5.5 * captions.duplicate: refuse an empty caption list and copy each caption once; move its UI test into the shared ui-egui test binary Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/edit/src/captions.rs | 95 ++++++++++++++ crates/engine/src/captions.rs | 38 ++++++ crates/engine/src/captions_tests.rs | 33 +++++ .../ui-egui/src/panels/timeline_captions.rs | 35 ++++-- .../tests/it/caption_option_drag_ui.rs | 117 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/captions.md | 6 +- 7 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/tests/it/caption_option_drag_ui.rs diff --git a/crates/edit/src/captions.rs b/crates/edit/src/captions.rs index a08d0ad7d..252c7e46e 100644 --- a/crates/edit/src/captions.rs +++ b/crates/edit/src/captions.rs @@ -215,6 +215,67 @@ pub fn move_captions(seq: &mut Sequence, ids: &[ClipId], delta: Tick) -> Result< Ok(d) } +/// Place copies of `captions` on `track`, overwriting what lies under them (captions there are cut +/// back, split or removed), as Paste and Option-drag do. The copies keep their text, speaker and +/// settings at their own `start`/`duration`, and get new ids (returned in time order). +pub fn overwrite_captions(seq: &mut Sequence, track: TrackId, captions: &[Caption], ctx: &mut EditCtx) -> Result> { + let min = ctx.min_duration; + let mut placed: Vec = captions.iter().filter(|c| c.duration >= min).cloned().collect(); + if placed.is_empty() { + return Err(EditError::Nothing); + } + placed.sort_by_key(|c| c.start); + if placed.first().is_some_and(|c| c.start < Tick::ZERO) { + return Err(EditError::Other("captions cannot start before the sequence".into())); + } + if placed.windows(2).any(|w| w[0].end() > w[1].start) { + return Err(EditError::Other("captions overlap".into())); + } + let tr = track_mut(seq, track)?; + for c in &mut placed { + c.id = ClipId(ctx.alloc()); + c.cue_id = None; + } + for c in &placed { + clear_range(tr, c.start, c.end(), min, ctx); + } + let ids = placed.iter().map(|c| c.id).collect(); + tr.captions.extend(placed); + tr.sort(); + Ok(ids) +} + +/// Clear `a..b` on a caption track without closing the gap: captions inside go, captions across an +/// edge are cut back, a caption across the whole range is split. Pieces shorter than `min` go. +fn clear_range(tr: &mut CaptionTrack, a: Tick, b: Tick, min: Tick, ctx: &mut EditCtx) { + let mut out = Vec::with_capacity(tr.captions.len() + 1); + for c in tr.captions.drain(..) { + let (s, e) = (c.start, c.end()); + if e <= a || s >= b { + out.push(c); + continue; + } + let mut kept_left = false; + if s < a && a - s >= min { + let mut l = c.clone(); + l.duration = a - s; + out.push(l); + kept_left = true; + } + if e > b && e - b >= min { + let mut r = c; + if kept_left { + r.id = ClipId(ctx.alloc()); + r.cue_id = None; + } + r.start = b; + r.duration = e - b; + out.push(r); + } + } + tr.captions = out; +} + /// Delete captions. With `ripple`, later captions on the same track move left to close each gap. pub fn delete_captions(seq: &mut Sequence, ids: &[ClipId], ripple: bool) -> Result { let mut n = 0; @@ -385,6 +446,40 @@ mod tests { assert_eq!(spans(&seq, tid), vec![(0, 40, "a".into()), (40, 80, "c".into())]); } + #[test] + fn overwrite_cuts_splits_and_removes_what_is_under() { + let (mut seq, tid) = seq_with(&[(0, 100, "a"), (100, 50, "b"), (200, 100, "c")]); + let mut n = 1; + let copy = |s: i64, d: i64, t: &str| Caption { + id: ClipId(1), + start: Tick(s), + duration: Tick(d), + text: t.into(), + speaker: None, + cue_id: Some("x".into()), + settings: String::new(), + }; + // across a's tail and all of b; then inside c (c is split around it) + let ids = overwrite_captions(&mut seq, tid, &[copy(60, 95, "x"), copy(230, 40, "y")], &mut ctx(&mut n)).unwrap(); + assert_eq!(ids.len(), 2); + assert_eq!(spans(&seq, tid), vec![(0, 60, "a".into()), (60, 155, "x".into()), (200, 230, "c".into()), (230, 270, "y".into()), (270, 300, "c".into())]); + let tr = seq.caption_track(tid).unwrap(); + assert!(tr.captions.iter().all(|c| c.cue_id.is_none())); + let seen: std::collections::HashSet = tr.captions.iter().map(|c| c.id.0).collect(); + assert_eq!(seen.len(), 5, "every piece has its own id"); + assert!(seq.check().is_ok()); + // a sliver under one frame is dropped, not kept + let (mut seq, tid) = seq_with(&[(0, 100, "a")]); + overwrite_captions(&mut seq, tid, &[copy(5, 90, "x")], &mut ctx(&mut n)).unwrap(); + assert_eq!(spans(&seq, tid), vec![(5, 95, "x".into())]); + // hostile input: before zero, overlapping copies, nothing + assert!(overwrite_captions(&mut seq, tid, &[copy(-5, 50, "x")], &mut ctx(&mut n)).is_err()); + assert!(overwrite_captions(&mut seq, tid, &[copy(0, 50, "x"), copy(40, 50, "y")], &mut ctx(&mut n)).is_err()); + assert_eq!(overwrite_captions(&mut seq, tid, &[], &mut ctx(&mut n)), Err(EditError::Nothing)); + seq.caption_track_mut(tid).unwrap().locked = true; + assert_eq!(overwrite_captions(&mut seq, tid, &[copy(0, 50, "x")], &mut ctx(&mut n)), Err(EditError::Locked)); + } + #[test] fn locked_refuses() { let (mut seq, tid) = seq_with(&[(0, 100, "a")]); diff --git a/crates/engine/src/captions.rs b/crates/engine/src/captions.rs index 02bddd456..d620a9f8d 100644 --- a/crates/engine/src/captions.rs +++ b/crates/engine/src/captions.rs @@ -467,6 +467,44 @@ pub fn commands() -> Vec { let applied = s.edit_sequence("Move Captions", |q, _, _| Ok(edit::captions::move_captions(q, &ids, d)?))?; Ok(json!({"applied": applied.0})) }), + spec("captions.duplicate", "Duplicate Captions", &[], None, r#"{"captions":[id]?,"delta":ticks|"deltaFrames":i64}"#, has_captions, |s, p| { + // Option-drag in the Timeline: copies `delta` later (or earlier) on their own + // tracks, overwriting what is there; the originals stay + let mut ids = caption_ids(s, p); + // each caption is copied once, however often it is named + let mut seen = std::collections::HashSet::new(); + ids.retain(|id| seen.insert(*id)); + if ids.is_empty() { + return Err(EngineError::Edit(edit::EditError::Nothing)); + } + let d = delta_p(s, p).ok_or_else(|| bad("captions.duplicate", "need `delta` or `deltaFrames`"))?; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let mut by_track: Vec<(TrackId, Vec)> = Vec::new(); + for id in &ids { + let (tid, c) = q.find_caption(*id).ok_or(EngineError::Edit(edit::EditError::NoItem(*id)))?; + match by_track.iter_mut().find(|(t, _)| *t == tid) { + Some((_, v)) => v.push(c.clone()), + None => by_track.push((tid, vec![c.clone()])), + } + } + // never before the start of the sequence + let earliest = by_track.iter().flat_map(|(_, v)| v.iter().map(|c| c.start)).min().unwrap_or_default(); + let d = d.max(-earliest); + if d == Tick::ZERO { + return Err(EngineError::Edit(edit::EditError::Nothing)); + } + let new = s.edit_sequence("Duplicate Captions", |q, ctx, st| { + let mut new = Vec::new(); + for (tid, caps) in &by_track { + let moved: Vec<_> = caps.iter().map(|c| filmcraft_project::Caption { start: c.start + d, ..c.clone() }).collect(); + new.extend(edit::captions::overwrite_captions(q, *tid, &moved, ctx)?); + } + st.caption_selection = new.clone(); + st.selection.clear(); + Ok(new) + })?; + Ok(json!({"captions": new.iter().map(|c| c.0).collect::>(), "delta": d.0})) + }), spec("captions.delete", "Delete Captions", &[], None, r#"{"captions":[id]?,"ripple":bool=false}"#, has_captions, |s, p| { let ids = caption_ids(s, p); delete(s, &ids, bool_p(p, "ripple").unwrap_or(false)) diff --git a/crates/engine/src/captions_tests.rs b/crates/engine/src/captions_tests.rs index feb65c175..2f33951e6 100644 --- a/crates/engine/src/captions_tests.rs +++ b/crates/engine/src/captions_tests.rs @@ -158,3 +158,36 @@ fn caption_list_track_filter_never_falls_back_to_every_track() { assert!(s.execute("captions.list", json!({"track": sel})).is_err(), "{sel} names no caption track"); } } + +fn caption_spans(s: &Session) -> Vec<(f64, f64, String)> { + let secs = |t: Tick| (t.seconds() * 10.0).round() / 10.0; // the demo sequence snaps to 23.976 fps frames + s.active_sequence().unwrap().caption_tracks[0].captions.iter().map(|c| (secs(c.start), secs(c.end()), c.text.clone())).collect() +} + +#[test] +fn captions_duplicate_copies_and_overwrites() { + let mut s = demo(); + for (sec, text) in [(1.0, "one"), (3.0, "two")] { + s.execute("playhead.set", json!({"seconds": sec})).unwrap(); + s.execute("captions.add", json!({"text": text, "durationSeconds": 1.0})).unwrap(); + } + let one = s.active_sequence().unwrap().caption_tracks[0].captions[0].id; + // Option-drag: a copy 5 s later, the original stays; the copy is selected + let r = s.execute("captions.duplicate", json!({"captions": [one.0], "delta": 5 * TICKS_PER_SECOND})).unwrap(); + assert_eq!(caption_spans(&s), vec![(1.0, 2.0, "one".into()), (3.0, 4.0, "two".into()), (6.0, 7.0, "one".into())]); + assert_eq!(s.state.caption_selection.len(), 1); + assert_eq!(r["captions"][0].as_u64(), Some(s.state.caption_selection[0].0)); + // a copy dropped half over `two` overwrites that half + s.execute("captions.duplicate", json!({"captions": [one.0], "delta": TICKS_PER_SECOND * 3 / 2})).unwrap(); + assert_eq!(caption_spans(&s)[1..3], [(2.5, 3.5, "one".into()), (3.5, 4.0, "two".into())]); + // never before zero; zero delta is nothing + s.execute("captions.duplicate", json!({"captions": [one.0], "delta": -100 * TICKS_PER_SECOND})).unwrap(); + assert_eq!(caption_spans(&s)[0], (0.0, 1.0, "one".into())); + assert!(s.execute("captions.duplicate", json!({"captions": [one.0], "delta": 0})).is_err()); + assert!(s.execute("captions.duplicate", json!({"captions": [one.0]})).is_err(), "needs a delta"); + assert!(s.execute("captions.duplicate", json!({"captions": [], "delta": TICKS_PER_SECOND})).is_err(), "needs a caption"); + let before = s.active_sequence().unwrap().caption_tracks[0].captions.len(); + s.execute("captions.duplicate", json!({"captions": [one.0, one.0], "delta": 20 * TICKS_PER_SECOND})).unwrap(); + assert_eq!(s.active_sequence().unwrap().caption_tracks[0].captions.len(), before + 1, "a caption named twice is copied once"); + s.active_sequence().unwrap().check().unwrap(); +} diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index 75cf0e71a..eb5869356 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -1,8 +1,8 @@ //! Caption track rows of the Timeline: drawn in their own area above the video tracks (Premiere's //! layout). Each row has a header (C1…, name, format, output eye, lock) and caption blocks in a -//! distinct colour showing their text. Click selects (Cmd/Shift adds), dragging the body moves, -//! dragging an edge trims, the Razor tool splits (Shift: every caption track), double-click opens -//! the Text panel's Captions tab. Every gesture ends in +//! distinct colour showing their text. Click selects (Cmd/Shift adds), dragging the body moves +//! the selection (Option-drag copies it), dragging an edge trims, the Razor tool splits (Shift: +//! every caption track), double-click opens the Text panel's Captions tab. Every gesture ends in //! one `captions.*` command; every element registers an automation id (`timeline.caption.*`, //! `timeline.captionTrack.C1.*`). @@ -45,10 +45,11 @@ fn drag_state(ctx: &egui::Context) -> Option<(u64, u8, i64)> { ctx.data(|d| d.get_temp::<(u64, u8, i64)>(drag_id())) } -fn block_rect(c: &filmcraft_project::Caption, row: Rect, layout: &Layout, drag: Option<(u64, u8, i64)>) -> Rect { +/// Where a caption is drawn while a drag is live: a move carries the whole selection along with +/// the grabbed caption, a trim only the grabbed one. +fn block_rect(c: &filmcraft_project::Caption, row: Rect, layout: &Layout, drag: Option<(u64, u8, i64)>, sel: &[ClipId]) -> Rect { let (mut s, mut e) = (c.start, c.end()); - if let Some((id, g, d)) = drag.filter(|d| d.0 == c.id.0) { - let _ = id; + if let Some((_, g, d)) = drag.filter(|d| moves_with(c.id, *d, sel)) { match g { 0 => { s += Tick(d); @@ -61,6 +62,11 @@ fn block_rect(c: &filmcraft_project::Caption, row: Rect, layout: &Layout, drag: Rect::from_min_max(pos2(layout.x_of(s), row.min.y + 2.0), pos2(layout.x_of(e).max(layout.x_of(s) + 1.0), row.max.y - 2.0)) } +/// Whether caption `id` follows the live drag `(grabbed, gesture, delta)`. +fn moves_with(id: ClipId, (grabbed, g, d): (u64, u8, i64), sel: &[ClipId]) -> bool { + d != 0 && (id.0 == grabbed || (g == 0 && sel.contains(&ClipId(grabbed)) && sel.contains(&id))) +} + /// Paint headers and caption blocks. pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Rect, layout: &Layout, t: &Tokens) { if seq.caption_tracks.is_empty() { @@ -141,8 +147,13 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re // caption blocks let p = painter.with_clip_rect(lane); let (v0, v1) = (layout.tick_at(content.min.x - 2.0), layout.tick_at(content.max.x + 2.0)); + let copying = drag.is_some_and(|d| d.1 == 0) && ui.input(|i| i.modifiers.alt); for c in tr.captions.iter().filter(|c| c.end() >= v0 && c.start <= v1) { - let r = block_rect(c, row, layout, drag); + let r = block_rect(c, row, layout, drag, &sel); + // Option-drag copies: the original stays where it is under the copy being placed + if copying && drag.is_some_and(|d| moves_with(c.id, d, &sel)) { + p.rect_filled(block_rect(c, row, layout, None, &sel), 3.0, FILL); + } let selected = sel.contains(&c.id); p.rect_filled(r, 3.0, if selected { FILL_SEL } else { FILL }); if selected { @@ -176,11 +187,12 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: let ctx = ui.ctx().clone(); let rate = seq.settings.frame_rate; let drag = drag_state(&ctx); + let sel = app.session.state.caption_selection.clone(); let mut actions: Vec<(String, Value)> = Vec::new(); for (tr, row) in seq.caption_tracks.iter().zip(rows(area, seq.caption_tracks.len())) { let lane = Rect::from_min_max(pos2(layout.content.min.x, row.min.y), pos2(layout.content.max.x, row.max.y)); for c in &tr.captions { - let r = block_rect(c, row, layout, drag).intersect(lane); + let r = block_rect(c, row, layout, drag, &sel).intersect(lane); if r.width() <= 0.0 { continue; } @@ -295,6 +307,9 @@ fn interact_block( && let (Some((id, g, _)), Some(p)) = (drag_state(&ctx), resp.interact_pointer_pos()) && id == c.id.0 { + if g == 0 && ctx.input(|i| i.modifiers.alt) { + ctx.set_cursor_icon(CursorIcon::Copy); + } let x0 = ctx.data(|d| d.get_temp::(egui::Id::new("caption-drag-origin"))).unwrap_or(p.x); let pps = app.ui.timeline.pps; let d = rate.snap_nearest(Tick::from_seconds_f64((p.x - x0) as f64 / pps)); @@ -314,7 +329,9 @@ fn interact_block( } else { vec![id] }; - actions.push(("captions.move".into(), json!({"captions": sel, "delta": d}))); + // Option held on the drop copies (Premiere: Option-drag duplicates) + let cmd = if ctx.input(|i| i.modifiers.alt) { "captions.duplicate" } else { "captions.move" }; + actions.push((cmd.into(), json!({"captions": sel, "delta": d}))); } 1 => actions.push(("captions.trim".into(), json!({"caption": id, "edge": "in", "delta": d}))), _ => actions.push(("captions.trim".into(), json!({"caption": id, "edge": "out", "delta": d}))), diff --git a/crates/ui-egui/tests/it/caption_option_drag_ui.rs b/crates/ui-egui/tests/it/caption_option_drag_ui.rs new file mode 100644 index 000000000..771ef0151 --- /dev/null +++ b/crates/ui-egui/tests/it/caption_option_drag_ui.rs @@ -0,0 +1,117 @@ +//! Option-drag of a caption in the Timeline leaves it where it was and drops a copy where it is +//! released, as with clips. The real `FilmcraftApp` under `egui_kittest` with the demo project. + +use std::sync::mpsc::{Sender, channel}; + +use egui::{Modifiers, Pos2, Rect, pos2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// The demo project with snapping off, so drags land where the pointer goes; frames 1/60 s + /// apart, as on a display. + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("sequence.snap", json!({"on": false})).expect("snapping off"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d.ok("ui.elements", json!({"prefix": "timeline.clip."})); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).expect("control channel"); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn rect(&mut self, id: &str) -> Option { + self.frames(2); + let els = self.ok("ui.elements", json!({"prefix": id})); + let el = els.as_array()?.iter().find(|e| e["id"] == id)?.clone(); + let r: Vec = el["rect"].as_array()?.iter().filter_map(|v| v.as_f64().map(|v| v as f32)).collect(); + Some(Rect::from_min_size(pos2(*r.first()?, *r.get(1)?), egui::vec2(*r.get(2)?, *r.get(3)?))) + } + + /// (start, end, text) of every caption on C1. + fn captions(&mut self) -> Vec<(i64, i64, String)> { + let r = self.exec("captions.list", json!({})); + r["tracks"][0]["captions"] + .as_array() + .expect("captions") + .iter() + .map(|c| (c["start"].as_i64().expect("start"), c["end"].as_i64().expect("end"), c["text"].as_str().expect("text").to_string())) + .collect() + } + + /// Press at `from`, move to `to` over `steps` frames with `mods` held, release. + fn drag_with(&mut self, from: Pos2, to: Pos2, steps: usize, mods: Modifiers) { + let push = |d: &mut Self, e: egui::Event| d.harness.input_mut().events.push(e); + push(self, egui::Event::ModifiersChanged(mods)); + push(self, egui::Event::PointerMoved(from)); + self.frames(1); + push(self, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: mods }); + self.frames(1); + for i in 1..=steps { + push(self, egui::Event::PointerMoved(from + (to - from) * (i as f32 / steps as f32))); + self.frames(1); + } + push(self, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: mods }); + self.frames(2); + push(self, egui::Event::ModifiersChanged(Modifiers::NONE)); + self.frames(1); + } +} + +/// Option-drag of a caption leaves it where it was and drops a copy where it is released; a +/// plain drag still moves it. +#[test] +fn option_drag_copies_a_caption() { + let mut d = Driver::demo(); + let id = d.exec("captions.add", json!({"seconds": 1.0, "durationSeconds": 1.0, "text": "twin"}))["caption"].as_u64().expect("id"); + let before = d.captions(); + let r = d.rect(&format!("timeline.caption.{id}")).expect("caption block"); + d.drag_with(r.center(), r.center() + egui::vec2(r.width() * 2.0, 0.0), 20, Modifiers::ALT); + let caps = d.captions(); + assert_eq!(caps.len(), 2, "a copy was made: {caps:?}"); + assert_eq!(caps[0], before[0], "the original stayed"); + assert_eq!(caps[1].2, "twin"); + assert!(caps[1].0 > before[0].1, "the copy is to the right: {caps:?}"); + // without Option it moves + let r = d.rect(&format!("timeline.caption.{id}")).expect("caption block"); + d.drag_with(r.center(), r.center() - egui::vec2(r.width() * 0.5, 0.0), 20, Modifiers::NONE); + assert_eq!(d.captions().len(), 2, "a plain drag does not copy"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index d585570c7..3408b21da 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,5 +1,6 @@ mod audio51_ui; mod audio_fx_editor_ui; +mod caption_option_drag_ui; mod caption_properties_ui; mod caption_razor_ui; mod captions_font_ui; diff --git a/docs/captions.md b/docs/captions.md index d54cc4dac..6e79d56b0 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -46,6 +46,7 @@ Details of each format and the SCC encoder are in [`crates/captions/README.md`]( | `captions.setTimes` | Text panel in/out fields | `caption`, `start…`/`end…` (`Time` ticks, `Frame`, `Seconds`, `Timecode`) | | `captions.trim` | drag a caption edge in the timeline | `caption`, `edge` (`in`/`out`), `delta` or `deltaFrames` | | `captions.move` | drag a caption in the timeline | `captions`, `delta` or `deltaFrames` | +| `captions.duplicate` | ⌥-drag a caption in the timeline | `captions`, `delta` or `deltaFrames` — copies on the same tracks, overwriting what is there | | `captions.delete` | Edit ▸ Clear / Ripple Delete with captions selected | `captions`, `ripple` | | `captions.select`, `captions.goTo` | click / number button | `captions`, `add` / `caption` | | `captions.setTrack` | caption track header (eye, lock) | `track`, `name`, `format`, `language`, `enabled`, `locked`, `syncLock` | @@ -62,8 +63,9 @@ also splits captions. ## Timeline and Text panel - Caption rows: header with `C1…` badge, lock, output eye, name and format; caption blocks in - violet showing their text (Arabic and Hebrew in reading order from the block's right edge). Click selects (⌘/⇧ adds), drag moves, edge drag trims, the Razor tool cuts, - double-click opens the Text panel. Automation ids: `timeline.caption.`, + violet showing their text (Arabic and Hebrew in reading order from the block's right edge). Click selects (⌘/⇧ adds), drag moves (the whole selection; ⌥-drag + copies), edge drag trims, the Razor tool cuts, double-click + opens the Text panel. Automation ids: `timeline.caption.`, `timeline.captionTrack.C1[.locked|.enabled]`. - **Text panel ▸ Captions**: search, track picker (also creates tracks of each format), add / split / merge / delete / export buttons, the list of segments with editable in/out timecodes From bd4139e430397ac9fd79a447389495f47dc32dd7 Mon Sep 17 00:00:00 2001 From: Tex Jernigan Date: Sat, 24 Oct 2026 10:23:35 -0400 Subject: [PATCH 105/110] Edit: Copy, Cut and Paste work on captions selected in the Timeline (#3041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Edit: Copy, Cut and Paste work on captions selected in the Timeline With only captions selected, Edit ▸ Copy / Cut (⌘C / ⌘X) copy them to a caption clipboard, and Paste / Paste Insert (⌘V / ⇧⌘V) place them at the playhead on the caption tracks they came from (the last track when that one is gone), overwriting what is there; Paste Insert first pushes later captions on by the copied span. The pasted captions are selected, the playhead moves past them, and it is one undo step. Copying clips empties the caption clipboard and the other way round, so Paste places whichever was copied last. Before, Copy did nothing with only captions selected. Co-Authored-By: Claude Opus 5.5 * Move the caption copy/paste UI test into the shared ui-egui test binary Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/captions.rs | 52 ++++++++++++ crates/engine/src/captions_tests.rs | 33 +++++++ crates/engine/src/commands.rs | 24 +++++- crates/engine/src/explicit_targets_tests.rs | 2 +- crates/engine/src/lib.rs | 5 ++ .../ui-egui/tests/it/caption_copy_paste_ui.rs | 85 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/captions.md | 1 + 8 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/it/caption_copy_paste_ui.rs diff --git a/crates/engine/src/captions.rs b/crates/engine/src/captions.rs index d620a9f8d..3a13cbe9f 100644 --- a/crates/engine/src/captions.rs +++ b/crates/engine/src/captions.rs @@ -164,6 +164,58 @@ pub fn delete(s: &mut Session, ids: &[ClipId], ripple: bool) -> Result { Ok(json!({"deleted": n})) } +/// Copy the selected captions to the caption clipboard (Edit ▸ Copy / Cut with captions selected). +/// Returns how many were copied. +pub fn copy(s: &mut Session) -> usize { + let Some(q) = s.active_sequence() else { return 0 }; + let sel = &s.state.caption_selection; + let picked: Vec<(usize, filmcraft_project::Caption)> = + q.caption_tracks.iter().enumerate().flat_map(|(ti, t)| t.captions.iter().filter(|c| sel.contains(&c.id)).map(move |c| (ti, c.clone()))).collect(); + let min = picked.iter().map(|(_, c)| c.start).min().unwrap_or_default(); + s.state.caption_clipboard = picked.into_iter().map(|(ti, c)| (ti, filmcraft_project::Caption { start: c.start - min, ..c })).collect(); + s.state.clipboard.clear(); + s.state.clipboard_markers.clear(); + s.state.caption_clipboard.len() +} + +/// Paste the caption clipboard at the playhead, each caption on the caption track it was copied +/// from (the last one when that track is gone), overwriting what is there. Paste Insert first opens +/// a gap the copied span long on those tracks. The pasted captions are selected and the playhead +/// moves to their end. +pub fn paste(s: &mut Session, insert: bool) -> Result { + let at = s.playhead(); + let clip = s.state.caption_clipboard.clone(); + let span = clip.iter().map(|(_, c)| c.end()).max().unwrap_or_default(); + let mut by_track: Vec<(TrackId, Vec)> = Vec::new(); + { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + for (ti, c) in clip { + let Some(t) = q.caption_tracks.get(ti).or(q.caption_tracks.last()) else { + return Err(bad("edit.paste", "the sequence has no caption track to paste into")); + }; + let c = filmcraft_project::Caption { start: c.start + at, ..c }; + match by_track.iter_mut().find(|(id, _)| *id == t.id) { + Some((_, v)) => v.push(c), + None => by_track.push((t.id, vec![c])), + } + } + } + let ids = s.edit_sequence(if insert { "Paste Insert Captions" } else { "Paste Captions" }, |q, ctx, st| { + let mut ids = Vec::new(); + for (tid, caps) in &by_track { + if insert && let Some(tr) = q.caption_track_mut(*tid).filter(|t| !t.locked) { + edit::captions::insert_gap(tr, at, span, ctx); + } + ids.extend(edit::captions::overwrite_captions(q, *tid, caps, ctx)?); + } + st.caption_selection = ids.clone(); + st.selection.clear(); + Ok(ids) + })?; + s.set_playhead(at + span); + Ok(json!({"captions": ids.iter().map(|c| c.0).collect::>()})) +} + fn caption_json(s: &Session, c: &filmcraft_project::Caption) -> Value { let q = s.active_sequence(); let (rate, df) = q.map(|q| (q.settings.frame_rate, q.settings.drop_frame)).unwrap_or_default(); diff --git a/crates/engine/src/captions_tests.rs b/crates/engine/src/captions_tests.rs index 2f33951e6..a8b10c8db 100644 --- a/crates/engine/src/captions_tests.rs +++ b/crates/engine/src/captions_tests.rs @@ -191,3 +191,36 @@ fn captions_duplicate_copies_and_overwrites() { assert_eq!(s.active_sequence().unwrap().caption_tracks[0].captions.len(), before + 1, "a caption named twice is copied once"); s.active_sequence().unwrap().check().unwrap(); } + +#[test] +fn captions_copy_cut_and_paste() { + let mut s = demo(); + // Copy / Paste with only captions selected copies captions, pasted at the playhead + s.execute("playhead.set", json!({"seconds": 1.0})).unwrap(); + s.execute("captions.add", json!({"text": "hi", "durationSeconds": 1.0})).unwrap(); + assert!(s.state.selection.is_empty()); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["copied"], 1); + s.execute("playhead.set", json!({"seconds": 10.0})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(caption_spans(&s), vec![(1.0, 2.0, "hi".into()), (10.0, 11.0, "hi".into())]); + assert!((s.playhead().seconds() - 11.0).abs() < 0.05, "the playhead moves past the paste"); + // Cut takes it away and Paste puts it back + s.execute("edit.cut", json!({})).unwrap(); + assert_eq!(caption_spans(&s), vec![(1.0, 2.0, "hi".into())]); + s.execute("playhead.set", json!({"seconds": 4.0})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(caption_spans(&s)[1], (4.0, 5.0, "hi".into())); + // copying clips afterwards makes Paste place clips again, not captions + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id; + s.execute("timeline.select", json!({"clips": [clip.0]})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.state.caption_clipboard.is_empty() && !s.state.clipboard.is_empty()); + // undo takes back a caption paste as one step + let mut s = demo(); + s.execute("captions.add", json!({"text": "x", "durationSeconds": 1.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("playhead.set", json!({"seconds": 5.0})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(caption_spans(&s).len(), 1); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e08003673..e01fa8491 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -79,6 +79,11 @@ pub(crate) fn has_selection(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; if s.state.selection.is_empty() { Err("no clips selected".into()) } else { Ok(()) } } +/// Clips or captions selected (Copy / Cut work on either). +fn has_clips_or_captions(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + if s.state.selection.is_empty() && s.state.caption_selection.is_empty() { Err("no clips or captions selected".into()) } else { Ok(()) } +} /// Clips or captions selected (Clear / Ripple Delete work on either). fn has_any_selection(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; @@ -120,7 +125,7 @@ pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), Stri } fn has_clipboard(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; - if s.state.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) } + if s.state.clipboard.is_empty() && s.state.caption_clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) } } /// The ids a caller named under `many` (a list) or `one` (a single id), when the command documents @@ -1624,7 +1629,13 @@ fn build() -> Vec { // ================= Edit ================= cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!({"undone": s.undo()}))), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!({"redone": s.redo()}))), - cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, |s, _| { + cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_clips_or_captions, |s, _| { + if s.state.selection.is_empty() { + crate::captions::copy(s); + let caps = s.state.caption_selection.clone(); + crate::captions::delete(s, &caps, false)?; + return Ok(Value::Null); + } copy_selection(s); let sel = with_links(s, &s.state.selection.clone()); s.edit_sequence("Cut", |q, _, st| { @@ -1634,7 +1645,10 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, |s, _| { + cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_clips_or_captions, |s, _| { + if s.state.selection.is_empty() { + return Ok(json!({"copied": crate::captions::copy(s)})); + } copy_selection(s); Ok(json!({"copied": s.state.clipboard.len()})) }), @@ -3670,9 +3684,13 @@ fn copy_selection(s: &mut Session) { } s.state.clipboard = clip; s.state.clipboard_markers = markers; + s.state.caption_clipboard.clear(); } fn paste(s: &mut Session, insert: bool) -> Result { + if !s.state.caption_clipboard.is_empty() { + return crate::captions::paste(s, insert); + } let at = s.playhead(); let clip = s.state.clipboard.clone(); let markers = if s.state.copy_paste_sequence_markers { s.state.clipboard_markers.clone() } else { Vec::new() }; diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index 2abbcee12..cf1384595 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -130,7 +130,7 @@ fn naming_nothing_usable_does_not_enable_a_command() { assert!(why.starts_with("-1 is not a clip id"), "{why}"); assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [v1(&s)[0].id.0], "item": 987_654_321u64}))), "select a clip in the Project panel"); // `edit.cut` takes no `clips`: it works on the selection only - assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips selected"); + assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips or captions selected"); assert!(std::sync::Arc::ptr_eq(&before, &s.project), "nothing was edited"); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 47a9cfd6e..dd2680bb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -381,6 +381,11 @@ pub struct EditorState { /// Sequence markers copied with the clipboard clips (start relative to the copied range). #[serde(skip)] pub clipboard_markers: Vec, + /// Captions copied from the Timeline, with their caption track's index (starts relative to the + /// copied range). Copying clips empties it and copying captions empties `clipboard`, so Paste + /// places whichever was copied last. + #[serde(skip)] + pub caption_clipboard: Vec<(usize, filmcraft_project::Caption)>, /// The last Edit ▸ Find… (Find Next continues it). #[serde(default)] pub find: Option, diff --git a/crates/ui-egui/tests/it/caption_copy_paste_ui.rs b/crates/ui-egui/tests/it/caption_copy_paste_ui.rs new file mode 100644 index 000000000..c3852b7c7 --- /dev/null +++ b/crates/ui-egui/tests/it/caption_copy_paste_ui.rs @@ -0,0 +1,85 @@ +//! Cmd+C / Cmd+V with a caption selected in the Timeline pastes a copy at the playhead, as with +//! clips. The real `FilmcraftApp` under `egui_kittest` with the demo project. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// The demo project with snapping off, so drags land where the pointer goes; frames 1/60 s + /// apart, as on a display. + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("sequence.snap", json!({"on": false})).expect("snapping off"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d.ok("ui.elements", json!({"prefix": "timeline.clip."})); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).expect("control channel"); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + /// (start, end, text) of every caption on C1. + fn captions(&mut self) -> Vec<(i64, i64, String)> { + let r = self.exec("captions.list", json!({})); + r["tracks"][0]["captions"] + .as_array() + .expect("captions") + .iter() + .map(|c| (c["start"].as_i64().expect("start"), c["end"].as_i64().expect("end"), c["text"].as_str().expect("text").to_string())) + .collect() + } +} + +/// Cmd+C / Cmd+V with a caption selected in the Timeline pastes a copy at the playhead. +#[test] +fn copy_paste_duplicates_a_caption() { + let mut d = Driver::demo(); + let id = d.exec("captions.add", json!({"seconds": 1.0, "durationSeconds": 1.0, "text": "again"}))["caption"].as_u64().expect("id"); + d.ok("ui.click", json!({"id": format!("timeline.caption.{id}")})); + d.ok("ui.key", json!({"key": "Cmd+C"})); + d.exec("playhead.set", json!({"seconds": 5.0})); + d.ok("ui.key", json!({"key": "Cmd+V"})); + let caps = d.captions(); + assert_eq!(caps.len(), 2, "pasted: {caps:?}"); + assert_eq!(caps[1].2, "again"); + assert!(caps[1].0 > caps[0].1); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3408b21da..88587dde3 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,5 +1,6 @@ mod audio51_ui; mod audio_fx_editor_ui; +mod caption_copy_paste_ui; mod caption_option_drag_ui; mod caption_properties_ui; mod caption_razor_ui; diff --git a/docs/captions.md b/docs/captions.md index 6e79d56b0..b1000588e 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -48,6 +48,7 @@ Details of each format and the SCC encoder are in [`crates/captions/README.md`]( | `captions.move` | drag a caption in the timeline | `captions`, `delta` or `deltaFrames` | | `captions.duplicate` | ⌥-drag a caption in the timeline | `captions`, `delta` or `deltaFrames` — copies on the same tracks, overwriting what is there | | `captions.delete` | Edit ▸ Clear / Ripple Delete with captions selected | `captions`, `ripple` | +| `edit.copy` / `edit.cut` / `edit.paste` / `edit.pasteInsert` | ⌘C / ⌘X / ⌘V / ⇧⌘V with only captions selected | captions paste at the playhead on the tracks they came from, overwriting (Paste Insert pushes later captions on); Paste places whichever of clips or captions was copied last | | `captions.select`, `captions.goTo` | click / number button | `captions`, `add` / `caption` | | `captions.setTrack` | caption track header (eye, lock) | `track`, `name`, `format`, `language`, `enabled`, `locked`, `syncLock` | | `captions.setStyle` | Text panel style strip | `track`, `size`, `color`, `background`, `backgroundColor`, `outline`, `outlineColor`, `align`, `anchor`, `margin`, `lineSpacing`, `reset` | From 561322b46df6d1cdaffc1ef3ba1a3ee06cc7779f Mon Sep 17 00:00:00 2001 From: Tex Jernigan Date: Sat, 24 Oct 2026 10:24:03 -0400 Subject: [PATCH 106/110] Text panel: the Captions list follows the playhead (#3042) * Text panel: the Captions list follows the playhead As the playhead reaches a caption, the Captions list scrolls it to the middle, so the list stays lined up with the Timeline during playback and scrubbing. Each caption is brought into view once (scrolling the list by hand still works), and never while a field in the panel has focus. Co-Authored-By: Claude Opus 5.5 * Move the Captions list follow test into the shared ui-egui test binary Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/panels/text.rs | 10 ++ .../tests/it/caption_list_follow_ui.rs | 91 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/captions.md | 3 +- 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/it/caption_list_follow_ui.rs diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index b6eaca3c9..9702bd8b7 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -178,6 +178,9 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let q = app.ui.caption_search.to_lowercase(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(list.shrink(4.0)).id_salt("caption-list")); let current = track.caption_at(ph).map(|c| c.id); + if current.is_none() { + ui.data_mut(|d| d.remove::(egui::Id::new("caption-follow"))); + } egui::ScrollArea::vertical().auto_shrink([false, false]).id_salt("caption-scroll").show(&mut child, |ui| { ui.set_width(list.width() - 12.0); let mut shown = 0; @@ -266,6 +269,13 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let row = fr.response.rect; if current == Some(c.id) { ui.painter().rect_stroke(row, 3.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + // the list follows the playhead: each caption it reaches is brought to the middle + // once, but never while a field is being edited + let follow = egui::Id::new("caption-follow"); + if ui.data(|d| d.get_temp::(follow)) != Some(c.id.0) && ui.memory(|m| m.focused().is_none()) { + ui.scroll_to_rect(row, Some(egui::Align::Center)); + ui.data_mut(|d| d.insert_temp(follow, c.id.0)); + } } app.auto.add(&format!("text.captions.{}.row", c.id.0), row, &c.text); ui.add_space(2.0); diff --git a/crates/ui-egui/tests/it/caption_list_follow_ui.rs b/crates/ui-egui/tests/it/caption_list_follow_ui.rs new file mode 100644 index 000000000..310a4182a --- /dev/null +++ b/crates/ui-egui/tests/it/caption_list_follow_ui.rs @@ -0,0 +1,91 @@ +//! The Text panel's Captions list keeps the caption under the playhead in view as the playhead +//! moves, so it stays lined up with the Timeline. The real `FilmcraftApp` under `egui_kittest` +//! with the demo project. + +use std::sync::mpsc::{Sender, channel}; + +use egui::{Rect, pos2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// The demo project with snapping off, so drags land where the pointer goes; frames 1/60 s + /// apart, as on a display. + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("sequence.snap", json!({"on": false})).expect("snapping off"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d.ok("ui.elements", json!({"prefix": "timeline.clip."})); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).expect("control channel"); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn rect(&mut self, id: &str) -> Option { + self.frames(2); + let els = self.ok("ui.elements", json!({"prefix": id})); + let el = els.as_array()?.iter().find(|e| e["id"] == id)?.clone(); + let r: Vec = el["rect"].as_array()?.iter().filter_map(|v| v.as_f64().map(|v| v as f32)).collect(); + Some(Rect::from_min_size(pos2(*r.first()?, *r.get(1)?), egui::vec2(*r.get(2)?, *r.get(3)?))) + } +} + +/// The Captions list keeps the caption under the playhead in view as the playhead moves on. +#[test] +fn captions_list_follows_the_playhead() { + let mut d = Driver::demo(); + let mut last = 0; + for i in 0..30 { + last = d.exec("captions.add", json!({"seconds": i as f64, "durationSeconds": 0.5, "text": format!("word {i}")}))["caption"].as_u64().expect("id"); + } + d.ok("ui.panel.show", json!({"panel": "Text"})); + d.ok("ui.click", json!({"id": "text.tab.Captions"})); + d.exec("playhead.set", json!({"seconds": 0.0})); + let top = d.rect("text.captions.search").expect("search").max.y; + let bottom = d.rect("text.captions.style.font").expect("style strip").min.y; + let row = format!("text.captions.{last}.row"); + let before = d.rect(&row).expect("last row"); + assert!(before.center().y > bottom, "the last caption starts out of view: {before:?} below {bottom}"); + d.exec("playhead.set", json!({"seconds": 29.2})); + d.frames(30); + let after = d.rect(&row).expect("last row"); + assert!(after.center().y > top && after.center().y < bottom, "the playhead's caption is in view: {after:?} in {top}..{bottom}"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 88587dde3..57bfabdf0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,6 +1,7 @@ mod audio51_ui; mod audio_fx_editor_ui; mod caption_copy_paste_ui; +mod caption_list_follow_ui; mod caption_option_drag_ui; mod caption_properties_ui; mod caption_razor_ui; diff --git a/docs/captions.md b/docs/captions.md index b1000588e..e4212fe7d 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -72,7 +72,8 @@ also splits captions. split / merge / delete / export buttons, the list of segments with editable in/out timecodes and text (committed when the field loses focus; Arabic and Hebrew show in reading order and are edited in logical order while the field has focus), and the track style strip (size, colour, box, - alignment, position). Automation ids: `text.tab.Captions`, `text.captions.add|split|merge|delete|export|search|track`, + alignment, position). The list keeps the caption under the playhead in view (centred as the + playhead reaches it, not while a field is being edited). Automation ids: `text.tab.Captions`, `text.captions.add|split|merge|delete|export|search|track`, `text.captions..in|out|text|goto|row`, `text.captions.style.*`. ## Burn-in From b206b1332d4fdc5634471d93e10513fab067f26c Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sat, 24 Oct 2026 07:24:27 -0700 Subject: [PATCH 107/110] History: the panel menu deletes redoable actions and clears the history (#3075) The History panel's menu had no entries of its own. It now has Delete Redoable Actions, which asks for OK and then removes the dimmed steps after the current state, and Clear History, which removes every step. Neither one changes the project or its unsaved state. Backspace in the History panel opens the same confirmation. The engine commands are history.deleteRedoable and history.clear; both return {"deleted": n}. Co-authored-by: Brandon Thomas --- crates/engine/src/commands.rs | 6 +++ crates/engine/src/history_tests.rs | 77 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 18 +++++++ crates/engine/src/shortcut_presets.rs | 1 + crates/engine/src/shortcuts_tests.rs | 2 +- crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/zh-cn.tsv | 3 ++ crates/ui-egui/src/menus.rs | 6 +++ crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/panels/misc.rs | 70 ++++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 4 ++ crates/ui-egui/tests/it/panels_ui.rs | 42 +++++++++++++++ docs/keyboard.md | 2 +- 14 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/history_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e01fa8491..079f6c6db 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -112,6 +112,9 @@ fn can_undo(s: &Session) -> std::result::Result<(), String> { fn can_redo(s: &Session) -> std::result::Result<(), String> { if s.history.can_redo() { Ok(()) } else { Err("nothing to redo".into()) } } +fn has_history(s: &Session) -> std::result::Result<(), String> { + if s.history.can_undo() || s.history.can_redo() { Ok(()) } else { Err("the history is empty".into()) } +} fn has_in_out(s: &Session) -> std::result::Result<(), String> { let seq = s.active_sequence().ok_or("no sequence is open")?; if seq.mark_in.is_some() || seq.mark_out.is_some() { Ok(()) } else { Err("mark an In or Out point first".into()) } @@ -1629,6 +1632,9 @@ fn build() -> Vec { // ================= Edit ================= cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!({"undone": s.undo()}))), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!({"redone": s.redo()}))), + // the History panel menu; neither one changes the project or its unsaved state + cmd!("history.deleteRedoable", "Delete Redoable Actions", [], None, "{}", can_redo, |s, _| Ok(json!({"deleted": s.history.delete_redoable()}))), + cmd!("history.clear", "Clear History", [], None, "{}", has_history, |s, _| Ok(json!({"deleted": s.history.clear()}))), cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_clips_or_captions, |s, _| { if s.state.selection.is_empty() { crate::captions::copy(s); diff --git a/crates/engine/src/history_tests.rs b/crates/engine/src/history_tests.rs new file mode 100644 index 000000000..1f5b26191 --- /dev/null +++ b/crates/engine/src/history_tests.rs @@ -0,0 +1,77 @@ +//! The History panel menu: `history.deleteRedoable` (Delete Redoable Actions) forgets the steps +//! Redo would bring back, and `history.clear` (Clear History) forgets every step. Neither one +//! changes the project or its unsaved state. + +use serde_json::json; + +use crate::Session; + +/// The demo project with three labeled edits, then two of them undone. +fn three_edits_two_undone() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + for name in ["A", "B", "C"] { + s.execute("file.newBin", json!({"name": name})).unwrap(); + } + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s +} + +#[test] +fn delete_redoable_actions_forgets_the_undone_steps_only() { + let mut s = three_edits_two_undone(); + let before = s.execute("history.list", json!({})).unwrap(); + assert_eq!(before["redo"].as_array().unwrap().len(), 2, "{before}"); + let project = s.project.clone(); + let dirty = s.is_dirty(); + + let r = s.execute("history.deleteRedoable", json!({})).unwrap(); + assert_eq!(r, json!({"deleted": 2})); + let after = s.execute("history.list", json!({})).unwrap(); + assert_eq!(after["undo"], before["undo"], "the steps Undo takes back stay"); + assert_eq!(after["redo"], json!([])); + assert_eq!(*s.project, *project, "the project does not change"); + assert_eq!(s.is_dirty(), dirty); + assert!(s.execute("edit.redo", json!({})).is_err(), "nothing is left to redo"); + // Undo still takes back the step before + s.execute("edit.undo", json!({})).unwrap(); + // with nothing to redo the command is disabled + s.history.redo.clear(); + assert!(s.execute("history.deleteRedoable", json!({})).is_err()); +} + +#[test] +fn clear_history_forgets_every_step_and_keeps_the_project() { + let mut s = three_edits_two_undone(); + let undo = s.history.undo.len(); + assert!(undo > 0); + let project = s.project.clone(); + let dirty = s.is_dirty(); + + let r = s.execute("history.clear", json!({})).unwrap(); + assert_eq!(r, json!({"deleted": undo + 2})); + assert_eq!(s.execute("history.list", json!({})).unwrap(), json!({"undo": [], "redo": []})); + assert_eq!(*s.project, *project, "the project does not change"); + assert_eq!(s.is_dirty(), dirty); + assert!(s.execute("edit.undo", json!({})).is_err()); + assert!(s.execute("history.clear", json!({})).is_err(), "an empty history has nothing to clear"); + + // a new edit starts a new history and can be undone + s.execute("file.newBin", json!({"name": "D"})).unwrap(); + assert_eq!(s.history.undo.len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *project); +} + +#[test] +fn history_commands_ignore_unexpected_parameters() { + for p in [json!(null), json!([1, 2]), json!({"deleted": -1}), json!("x")] { + let mut s = three_edits_two_undone(); + let undo = s.history.undo.len(); + assert_eq!(s.execute("history.deleteRedoable", p.clone()).unwrap(), json!({"deleted": 2}), "{p}"); + assert_eq!(s.history.undo.len(), undo, "{p}"); + assert_eq!(s.execute("history.clear", p.clone()).unwrap(), json!({"deleted": undo}), "{p}"); + assert_eq!(s.execute("history.list", json!({})).unwrap(), json!({"undo": [], "redo": []}), "{p}"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dd2680bb4..f711ad190 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -242,6 +242,22 @@ impl History { pub fn can_redo(&self) -> bool { !self.redo.is_empty() } + /// The History panel's Delete Redoable Actions: removes every step that Redo would bring + /// back. The project and the undo steps stay as they are. Returns how many steps were removed. + pub fn delete_redoable(&mut self) -> usize { + let n = self.redo.len(); + self.redo.clear(); + n + } + /// The History panel's Clear History: removes every undo and redo step. The project stays as + /// it is. Returns how many steps were removed. + pub fn clear(&mut self) -> usize { + let n = self.undo.len() + self.redo.len(); + self.undo.clear(); + self.redo.clear(); + self.merge_key = None; + n + } } /// Source patching / track targeting (the track header "V1/A1" source buttons + target toggles). @@ -1265,6 +1281,8 @@ mod file_tests; #[cfg(test)] mod gap_selection_tests; #[cfg(test)] +mod history_tests; +#[cfg(test)] mod image_sequence_tests; #[cfg(test)] mod individual_selection_tests; diff --git a/crates/engine/src/shortcut_presets.rs b/crates/engine/src/shortcut_presets.rs index 7e105e509..14bd2bf2a 100644 --- a/crates/engine/src/shortcut_presets.rs +++ b/crates/engine/src/shortcut_presets.rs @@ -264,6 +264,7 @@ pub const PREMIERE: &[Entry] = &[ ("source.open", "Shift+O", "Project"), ("edit.undo", "Left", "History"), ("edit.redo", "Right", "History"), + ("history.deleteRedoable", "Backspace", "History"), ]; /// Premiere's panel-scoped default keys for the keyboard-only commands (M3.12). FilmCraft Default diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index c6beec578..4aeb6e824 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -288,7 +288,7 @@ fn list_searches_labels_and_keys() { let v = s.execute("shortcuts.list", json!({"query": "cmd+k", "platform": "windows"})).unwrap(); assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "sequence.addEdit"), "{v}"); let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap(); - assert_eq!(v.as_array().unwrap().len(), 2, "{v}"); + assert_eq!(v.as_array().unwrap().len(), 3, "{v}"); } #[test] diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 214499151..19fbf457c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -169,6 +169,9 @@ Hide All Caption Tracks Ocultar todas las pistas de subtítulos High Quality Playback Reproducción de alta calidad History Historial + Delete Redoable Actions Eliminar acciones que se pueden rehacer + Clear History Borrar historial + Delete the dimmed actions after the current state? They cannot be redone. ¿Eliminar las acciones atenuadas posteriores al estado actual? No se podrán rehacer. Import From Importar desde Import Image Sequence… Importar secuencia de imágenes… Import from Media Browser Importar desde el navegador de medios diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8446dac2b..fac3d1d9d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -171,6 +171,9 @@ Hide All Caption Tracks すべてのキャプショントラックを非表示 High Quality Playback 高画質再生 History 履歴 + Delete Redoable Actions やり直し可能な操作を削除 + Clear History 履歴を消去 + Delete the dimmed actions after the current state? They cannot be redone. 現在の状態より後の淡色表示の操作を削除しますか? 削除した操作はやり直せません。 Import From 読み込み元 Import Image Sequence… 連番画像を読み込み… Import from Media Browser メディアブラウザーから読み込み diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 647661bad..262632a97 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -164,6 +164,9 @@ Hide All Caption Tracks 隐藏所有字幕轨道 High Quality Playback 高质量播放 History 历史记录 + Delete Redoable Actions 删除可重做的操作 + Clear History 清除历史记录 + Delete the dimmed actions after the current state? They cannot be redone. 删除当前状态之后显示为灰色的操作?删除后将无法重做。 Import From 导入来源 Import Image Sequence… 导入图像序列… Import from Media Browser 从媒体浏览器导入 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d4742ff59..2ee0a8e0a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -287,6 +287,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: if let Some(r) = crate::panels::clip_dialogs::route(app, id, ¶ms) { return r; } + if let Some(r) = crate::panels::misc::route_history(app, ctx, id, ¶ms) { + if let Err(e) = &r { + app.ui.status = e.clone(); + } + return r; + } if let Some(r) = crate::panels::settings::route(app, id) { return r; } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 8bc23dbc3..b8d738aee 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -23,6 +23,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { crate::panels::frame_export::show(app, ctx); crate::panels::project_dialogs::show(app, ctx); crate::panels::media_browser::dialogs(app, ctx); + crate::panels::misc::history_dialogs(app, ctx); let Some(d) = app.dialog else { return }; if let Some(still_open) = crate::panels::file_dialogs::show(app, ctx, d) { if !still_open && app.dialog == Some(d) { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index f5bc5f1e9..2196c1c81 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -50,6 +50,76 @@ pub fn history(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// Egui memory key of the open Delete Redoable Actions confirmation. +const CONFIRM_DELETE_REDOABLE: &str = "history-delete-redoable"; + +/// The History panel's ≡ menu: Delete Redoable Actions and Clear History (automation ids +/// `panel.menu.History.deleteRedoable` and `panel.menu.History.clear`). Returns true to close it. +pub fn history_panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { + let ctx = ui.ctx().clone(); + let mut close = false; + for (id, label, command) in [("deleteRedoable", tl!("Delete Redoable Actions"), "history.deleteRedoable"), ("clear", tl!("Clear History"), "history.clear")] + { + let r = ui.add_enabled(app.session.is_enabled(command), egui::Button::new(label)); + app.auto.add_response(&format!("panel.menu.History.{id}"), &r, label); + if r.clicked() { + if let Err(e) = crate::menus::invoke(app, &ctx, command, serde_json::json!({})) { + app.ui.status = e; + } + close = true; + } + } + close +} + +/// Delete Redoable Actions from the panel menu or a key (no parameters) asks for OK first, as in +/// Premiere. Returns None for other ids, and for calls with parameters, which run the command. +pub fn route_history(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &serde_json::Value) -> Option> { + if id != "history.deleteRedoable" || !params.as_object().is_none_or(|m| m.is_empty()) { + return None; + } + if !app.session.history.can_redo() { + return Some(Err("nothing to redo".into())); + } + ctx.data_mut(|d| d.insert_temp(egui::Id::new(CONFIRM_DELETE_REDOABLE), true)); + Some(Ok(serde_json::json!({"dialog": "deleteRedoable"}))) +} + +/// The Delete Redoable Actions confirmation (automation ids `history.deleteRedoable.cancel` and +/// `history.deleteRedoable.ok`). It closes by itself when nothing is left to redo. +pub fn history_dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { + let key = egui::Id::new(CONFIRM_DELETE_REDOABLE); + if !ctx.data(|d| d.get_temp::(key)).unwrap_or(false) { + return; + } + let mut close = !app.session.history.can_redo(); + egui::Window::new(tl!("Delete Redoable Actions")) + .id(key.with("window")) + .collapsible(false) + .resizable(false) + .pivot(Align2::CENTER_CENTER) + .default_pos(crate::widgets::dialog_center(ctx)) + .show(ctx, |ui| { + ui.label(tl!("Delete the dimmed actions after the current state? They cannot be redone.")); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + app.auto.add("history.deleteRedoable.cancel", r.rect, "Cancel"); + close |= r.clicked(); + let r = ui.button(tl!("OK")); + app.auto.add("history.deleteRedoable.ok", r.rect, "OK"); + if r.clicked() { + if let Err(e) = app.session.execute("history.deleteRedoable", serde_json::json!({})) { + app.ui.status = e.to_string(); + } + close = true; + } + }); + }); + if close || crate::widgets::escape_closes(ctx) { + ctx.data_mut(|d| d.remove::(key)); + } +} + /// The marker colours of the Markers panel filter (each chip stands for the labels that share its /// marker colour). pub const MARKER_FILTER: [filmcraft_project::Label; 7] = { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0f21158fc..532f8db17 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -352,6 +352,10 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { ui.separator(); close |= monitor::source_panel_menu(app, ui); } + if p == PanelKind::History { + ui.separator(); + close |= misc::history_panel_menu(app, ui); + } }); app.auto.add("panel.menu.scroll", scroll.inner_rect, "Panel menu"); }); diff --git a/crates/ui-egui/tests/it/panels_ui.rs b/crates/ui-egui/tests/it/panels_ui.rs index 150f4d0bf..768bfd785 100644 --- a/crates/ui-egui/tests/it/panels_ui.rs +++ b/crates/ui-egui/tests/it/panels_ui.rs @@ -210,6 +210,48 @@ fn panel_menu_maximize_restores_and_an_empty_dock_reopens() { assert!(d.has("panel.tab.Program") && d.has("panel.tab.Timeline")); } +/// The History panel menu: Delete Redoable Actions asks for OK and then forgets the dimmed steps +/// (Backspace in the History panel asks the same), and Clear History forgets every step. The +/// project stays as it is. +#[test] +fn history_panel_menu_deletes_redoable_actions_and_clears_the_history() { + let mut d = Driver::demo(); + for name in ["A", "B", "C"] { + d.exec("file.newBin", json!({"name": name})); + } + d.exec("edit.undo", json!({})); + d.exec("edit.undo", json!({})); + d.ok("ui.menu.invoke", json!({"id": "window.panel.History"})); + d.frames(3); + let redo = |d: &mut Driver| d.exec("history.list", json!({}))["redo"].as_array().unwrap().len(); + assert_eq!(redo(&mut d), 2); + let project = d.app().session.project.clone(); + + d.click("panel.menu.History"); + assert_eq!(d.label("panel.menu.History.deleteRedoable"), "Delete Redoable Actions"); + assert_eq!(d.label("panel.menu.History.clear"), "Clear History"); + d.click("panel.menu.History.deleteRedoable"); + assert!(d.has("history.deleteRedoable.ok"), "Delete Redoable Actions asks first"); + assert_eq!(redo(&mut d), 2, "nothing is deleted before OK"); + d.click("history.deleteRedoable.cancel"); + assert!(!d.has("history.deleteRedoable.ok")); + assert_eq!(redo(&mut d), 2, "Cancel keeps the steps"); + + d.ok("ui.key", json!({"key": "Backspace"})); + d.frames(2); + assert!(d.has("history.deleteRedoable.ok"), "Backspace in the History panel asks too"); + d.click("history.deleteRedoable.ok"); + assert!(!d.has("history.deleteRedoable.ok")); + assert_eq!(redo(&mut d), 0); + assert_eq!(*d.app().session.project, *project, "the project does not change"); + + d.click("panel.menu.History"); + d.click("panel.menu.History.clear"); + assert_eq!(d.exec("history.list", json!({})), json!({"undo": [], "redo": []})); + assert_eq!(*d.app().session.project, *project, "the project does not change"); + assert!(!d.app().session.is_enabled("history.clear"), "an empty history has nothing to clear"); +} + /// The wheel over a tab strip switches that group's tab: down = next, up = previous, one tab per /// notch, smaller trackpad steps adding up to a notch, stopping at the ends. Over the panel's /// content the wheel leaves the tabs alone. diff --git a/docs/keyboard.md b/docs/keyboard.md index a9eade807..4ca06cfa8 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -30,6 +30,7 @@ Premiere has these as keys only (most are not in a menu). Modules: `filmcraft_en | Panels | Maximize or Restore Active Frame / Frame Under Cursor, Select Next/Previous Panel, Toggle Source/Program Monitor Focus, Workspaces 7–9 | Shift+`, `, Ctrl+Shift+. / ,, Alt+Shift+7/8/9 | | Audio | Increase/Decrease Clip Volume (1 dB) and …Many (Settings ▸ Audio ▸ Large Volume Adjustment), Nudge Volume ±1/±3 dB, Toggle Audio During Scrubbing | ] / [, Shift+] / Shift+[, Shift+S | | Graphics | Increase/Decrease Font Size and Leading by One/Five Units, Left/Center/Right align text, Begin Text Editing, Nudge Selected Object by one/five (graphic layers, else Motion position) | Cmd+Alt+(Shift+)Left/Right, Alt+(Shift+)Up/Down, Cmd+Shift+L/C/R, Cmd+Alt+', Cmd+(Shift+)arrows (Program, Properties) | +| History panel | Delete (Delete Redoable Actions, which asks for OK first) | Backspace | | Project panel | List/Icon/Toggle View, Hover Scrub, Thumbnail Size Next/Previous (and = / -), Move/Extend Selection, Mark In / Out of the hover-scrubbed clip | Cmd+PageUp/PageDown, Shift+\, Shift+H, Shift+] / Shift+[, arrows, Home/End, PageUp/PageDown, I / O | | Text panel | Navigate/Select to Previous/Next Word and Line, Start/End of Segment, Delete (lift), Ripple Delete (extract), Show Program Transcript, Merge/Split Segments | arrows, Shift+arrows, Home/End, Shift+Home, Cmd+Shift+Down, Alt+Backspace, Backspace, Shift+X, Alt+M / Alt+S | | Other | Help (F1), Quit (Cmd+Q), Media Browser ▸ Open In Source Monitor (imports the file first), Send to Media Encoder (= Send to Export Queue, Alt+Shift+M), Metadata panel Play/Loop | | @@ -55,7 +56,6 @@ Frame has no default key; the Premiere preset moves Shift+E to Export Frame and | Audio Track Mixer: Show/Hide Tracks… (⌥⌘T), Meter Input(s) Only (⌃⇧I) | The mixer has no track visibility dialog and no input metering (no hardware input recording). | | Effect Controls: Remove Selected Effect (Delete), Loop During Audio-Only Playback (⌘L) | Effect Controls has no effect selection state and no audio-only playback mode; effects are removed from their context menu or `effects.remove`. | | Effects panel: New Custom Bin (⌘/), Delete Custom Item (Delete) | The Effects panel has no custom bins (effect presets live in the Presets bin). | -| History panel: Delete (Delete) | History states cannot be selected and deleted individually. | | Media Browser: Select Directory List / Select Media List (⇧← / ⇧→) | The directory tree and media list take no separate keyboard focus. | | Project panel: Delete Selection with Options (⌘Delete) | Needs the "delete instances in sequences" dialog; plain Clear (Backspace) deletes. | | Project panel: Next/Previous Column Field, Next/Previous Row Field (Tab, ⇧Tab, Return, ⇧Return) | The list view has no inline-editable metadata cells. | From cb6dee282dd59f36bfbd4a332c77d4fe5672fa64 Mon Sep 17 00:00:00 2001 From: thiscallnet <55761090+thiscallnet@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:24:30 +0200 Subject: [PATCH 108/110] media: import animated WebP as a clip (#3038) * media: import animated WebP as a clip * animated WebP test: use is_multiple_of (clippy) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- README.md | 2 +- crates/media/src/animated.rs | 46 +++++++++-- crates/media/src/animated_tests.rs | 119 ++++++++++++++++++++++++++++- crates/media/src/lib.rs | 2 +- 4 files changed, 157 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 102e4a2a3..95fb0a8c2 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks | | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | | **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet | -| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF and APNG as clips, with transparency); export PNG sequences and animated GIF | +| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF, APNG and WebP as clips, with transparency); export PNG sequences and animated GIF | Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)). diff --git a/crates/media/src/animated.rs b/crates/media/src/animated.rs index e20188a14..b327b89ed 100644 --- a/crates/media/src/animated.rs +++ b/crates/media/src/animated.rs @@ -1,11 +1,11 @@ -//! Animated images (APNG and animated GIF) as movie clips (#402). +//! Animated images (APNG, animated GIF, and animated WebP) as movie clips (#402). //! //! The still decoders read only the first picture of these files. [`open`] decodes every frame //! instead, composited onto the full canvas by the `image` crate (APNG blend and dispose operations, -//! GIF disposal methods), and keeps them with their timing: +//! GIF and WebP disposal methods), and keeps them with their timing: //! -//! - each frame lasts its own delay; GIF delays of 10 ms or less play as 100 ms and APNG delays -//! under 10 ms as 10 ms, as browsers do, so a zero delay never makes a zero-length frame; +//! - each frame lasts its own delay; GIF delays of 10 ms or less play as 100 ms and APNG and WebP +//! delays under 10 ms as 10 ms, as browsers do, so a zero delay never makes a zero-length frame; //! - consecutive identical frames are merged (one picture, the sum of their delays); //! - the clip is one pass through the animation (the file's loop count is not applied); //! - transparency is kept: the source reports alpha when any frame has a pixel that is not fully @@ -31,29 +31,37 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, pub const MAX_FRAMES: usize = 10_000; /// Most bytes of decoded RGBA pictures kept from one file (after merging identical frames). pub const MAX_DECODED_BYTES: usize = 1 << 30; +/// Most RGBA bytes in one animation canvas while its decoder produces a frame. +const MAX_DECODED_FRAME_BYTES: usize = 512 << 20; /// Animated image formats. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Format { Apng, Gif, + WebP, } impl Format { + /// Identify an animated image format from its container signature. fn sniff(b: &[u8]) -> Option { if b.starts_with(b"\x89PNG\r\n\x1a\n") { Some(Format::Apng) } else if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") { Some(Format::Gif) + } else if b.get(0..4) == Some(&b"RIFF"[..]) && b.get(8..12) == Some(&b"WEBP"[..]) { + Some(Format::WebP) } else { None } } + /// Return the codec label shown in media metadata. fn codec(self) -> &'static str { match self { Format::Apng => "APNG", Format::Gif => "GIF", + Format::WebP => "WebP", } } @@ -66,6 +74,7 @@ impl Format { let (numer, denom) = match self { Format::Gif if ms <= 10 => (100, 1), Format::Apng if ms < 10 => (10, 1), + Format::WebP if ms < 10 => (10, 1), _ => (numer, denom), }; let ticks = numer.saturating_mul(TICKS_PER_SECOND as u128) / denom.saturating_mul(1000); @@ -81,8 +90,8 @@ pub struct AnimatedSource { starts: Vec, } -/// Decode `bytes` as an animated image. `None` when it is not an APNG or GIF with at least two -/// frames that decode (the caller then opens it as a still). +/// Decode `bytes` as an animated image. `None` when it is not an APNG, GIF, or animated WebP with +/// at least two frames that decode (the caller then opens it as a still). pub fn open(name: &str, bytes: &[u8]) -> Option> { let format = Format::sniff(bytes)?; let frames = match format { @@ -99,19 +108,34 @@ pub fn open(name: &str, bytes: &[u8]) -> Option> { d.set_limits(Limits::default()).ok()?; d.into_frames() } + Format::WebP => { + let mut d = image::codecs::webp::WebPDecoder::new(Cursor::new(bytes)).ok()?; + if !d.has_animation() { + return None; + } + let (width, height) = d.dimensions(); + if rgba_bytes(width, height).is_none_or(|bytes| bytes > MAX_DECODED_FRAME_BYTES) { + return Some(Err(MediaError::Unsupported(format!("{name}: animated WebP canvas exceeds the {MAX_DECODED_FRAME_BYTES}-byte frame limit")))); + } + d.set_limits(Limits::default()).ok()?; + d.into_frames() + } }; AnimatedSource::from_frames(name, format, frames, bytes.len() as u64).map(Ok) } impl AnimatedSource { + /// Build a clip from decoded full-canvas frames, merging consecutive identical pictures. fn from_frames(name: &str, format: Format, frames: Frames<'_>, file_size: u64) -> Option { let mut pictures: Vec<(u32, u32, Vec)> = Vec::new(); let mut durations: Vec = Vec::new(); + let mut decoded_frames = 0usize; let mut decoded = 0usize; let mut has_alpha = false; for frame in frames.take(MAX_FRAMES) { // a damaged frame ends the animation at the frames before it let Ok(frame) = frame else { break }; + decoded_frames = decoded_frames.saturating_add(1); let ticks = format.frame_ticks(frame.delay()); let buffer = frame.into_buffer(); let (w, h) = buffer.dimensions(); @@ -131,7 +155,7 @@ impl AnimatedSource { pictures.push((w, h, rgba)); durations.push(ticks); } - if pictures.len() < 2 { + if pictures.len() < 2 && !(format == Format::WebP && decoded_frames >= 2) { return None; } let (width, height) = pictures.first().map(|p| (p.0, p.1))?; @@ -182,6 +206,11 @@ impl AnimatedSource { } } +/// Checked byte size of an RGBA8 image. +fn rgba_bytes(width: u32, height: u32) -> Option { + usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?.checked_mul(4) +} + /// The clip's frame rate: the rate of its most common frame duration, when that is a sensible video /// rate (1 to 120 frames per second), else 30 fps. Frames are shown for their own durations /// whatever this says; it sets the clip's timebase (e.g. a sequence made from the clip). @@ -207,14 +236,17 @@ fn rate_of(durations: &[Tick]) -> FrameRate { } impl MediaSource for AnimatedSource { + /// Return the clip's media metadata. fn info(&self) -> &MediaInfo { &self.info } + /// Return the picture displayed at the requested media time. fn video_frame(&self, req: FrameRequest) -> Result> { self.frames.get(self.index_at(req.time)).cloned().ok_or(MediaError::NoStream("video")) } + /// Animated image files do not provide audio. fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result { Err(MediaError::NoStream("audio")) } diff --git a/crates/media/src/animated_tests.rs b/crates/media/src/animated_tests.rs index be77ca879..d3db3b7de 100644 --- a/crates/media/src/animated_tests.rs +++ b/crates/media/src/animated_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::{FrameRequest, MediaKind, open_bytes}; +use crate::{FrameRequest, MediaError, MediaKind, open_bytes}; const W: u32 = 4; const H: u32 = 2; @@ -51,10 +51,71 @@ fn gif(frames: &[(Vec, u32)]) -> Vec { out } +/// A small animated WebP built from lossless WebP frame bitstreams. +fn webp(frames: &[(Vec, u32)]) -> Vec { + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(kind); + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_le_bytes()); + out.extend_from_slice(data); + if !data.len().is_multiple_of(2) { + out.push(0); + } + } + + fn u24(out: &mut [u8], value: u32) { + out[0] = value as u8; + out[1] = (value >> 8) as u8; + out[2] = (value >> 16) as u8; + } + + fn frame_chunk(pixels: &[u8], duration_ms: u32) -> Vec { + let mut encoded = Vec::new(); + image::codecs::webp::WebPEncoder::new_lossless(&mut encoded).encode(pixels, W, H, image::ExtendedColorType::Rgba8).unwrap(); + + let mut pos = 12; + let bitstream = loop { + let header = encoded.get(pos..pos + 8).unwrap(); + let size = u32::from_le_bytes(header[4..8].try_into().unwrap()) as usize; + let end = pos + 8 + size + size % 2; + if &header[..4] == b"VP8L" { + break encoded.get(pos..end).unwrap().to_vec(); + } + pos = end; + }; + + let mut data = vec![0; 16]; + u24(&mut data[6..9], W - 1); + u24(&mut data[9..12], H - 1); + u24(&mut data[12..15], duration_ms); + data[15] = 0x02; // replace the previous canvas instead of blending + data.extend_from_slice(&bitstream); + data + } + + let mut chunks = Vec::new(); + let mut extended = [0; 10]; + extended[0] = 0x12; // alpha and animation flags + u24(&mut extended[4..7], W - 1); + u24(&mut extended[7..10], H - 1); + chunk(&mut chunks, b"VP8X", &extended); + chunk(&mut chunks, b"ANIM", &[0, 0, 0, 0, 0, 0]); + for (pixels, duration_ms) in frames { + chunk(&mut chunks, b"ANMF", &frame_chunk(pixels, *duration_ms)); + } + + let mut out = b"RIFF".to_vec(); + out.extend_from_slice(&u32::try_from(chunks.len() + 4).unwrap().to_le_bytes()); + out.extend_from_slice(b"WEBP"); + out.extend_from_slice(&chunks); + out +} + +/// Convert a whole number of milliseconds to media ticks. fn ms(n: i64) -> Tick { Tick(n * TICKS_PER_SECOND / 1000) } +/// Read the decoded RGBA pixels shown at a media time. fn pixels(s: &dyn MediaSource, t: Tick) -> Vec { match &s.video_frame(FrameRequest::full(t)).unwrap().data { filmcraft_frame::PixelData::Rgba8(p) => p.to_vec(), @@ -111,13 +172,62 @@ fn animated_gif_plays_every_frame_with_transparency() { assert_eq!(&pixels(&*s, ms(40))[0..4], &[0, 255, 0, 255]); } +#[test] +fn animated_webp_plays_every_frame_with_timing_and_transparency() { + let (red, green) = (picture([255, 0, 0], 128), picture([0, 255, 0], 0)); + let bytes = webp(&[(red.clone(), 40), (green.clone(), 60)]); + let s = open_bytes("wave.webp", bytes.into(), &[]).unwrap(); + let info = s.info(); + assert_eq!(info.kind, MediaKind::Movie); + assert_eq!(info.video.as_ref().unwrap().codec, "WebP"); + assert_eq!(info.duration, ms(100)); + assert!(info.video.as_ref().unwrap().has_alpha); + assert_eq!(pixels(&*s, Tick::ZERO), red); + assert_eq!(pixels(&*s, ms(39)), red); + assert_eq!(pixels(&*s, ms(40)), green); +} + +#[test] +fn identical_webp_frames_remain_a_clip_when_merged() { + let frame = picture([20, 40, 60], 255); + let bytes = webp(&[(frame.clone(), 40), (frame, 60)]); + let decoded = open("hold.webp", &bytes).expect("animated WebP").expect("decodes"); + assert_eq!(decoded.frame_count(), 1, "equal pictures still merge"); + assert_eq!(decoded.info().kind, MediaKind::Movie); + assert_eq!(decoded.info().duration, ms(100), "merged frames keep their encoded duration"); + + let imported = open_bytes("hold.webp", bytes.into(), &[]).unwrap(); + assert_eq!(imported.info().kind, MediaKind::Movie, "the still-image fallback must not discard the duration"); + assert_eq!(imported.info().duration, ms(100)); +} + +#[test] +fn animated_webp_rejects_an_oversized_canvas_before_decoding_frames() { + let frame = picture([1, 2, 3], 255); + let mut bytes = webp(&[(frame.clone(), 40), (frame, 60)]); + // VP8X canvas width and height are stored as 24-bit values minus one. + for (offset, dimension) in [(24, 25_000u32), (27, 25_000u32)] { + let value = (dimension - 1).to_le_bytes(); + bytes[offset..offset + 3].copy_from_slice(&value[..3]); + } + assert!(matches!( + open("large.webp", &bytes), + Some(Err(MediaError::Unsupported(message))) if message.contains("frame limit") + )); +} + #[test] fn single_frame_files_stay_stills() { let one_frame_apng = apng(&[(picture([1, 2, 3], 255), (1, 10))]); let one_frame_gif = gif(&[(picture([1, 2, 3], 255), 100)]); + let one_frame_webp = webp(&[(picture([1, 2, 3], 255), 100)]); let mut plain = std::io::Cursor::new(Vec::new()); image::RgbaImage::from_raw(W, H, picture([1, 2, 3], 0)).unwrap().write_to(&mut plain, image::ImageFormat::Png).unwrap(); - for (name, bytes) in [("one.png", one_frame_apng), ("one.gif", one_frame_gif), ("plain.png", plain.into_inner())] { + let mut plain_webp = Vec::new(); + image::codecs::webp::WebPEncoder::new_lossless(&mut plain_webp).encode(&picture([1, 2, 3], 0), W, H, image::ExtendedColorType::Rgba8).unwrap(); + for (name, bytes) in + [("one.png", one_frame_apng), ("one.gif", one_frame_gif), ("one.webp", one_frame_webp), ("plain.png", plain.into_inner()), ("plain.webp", plain_webp)] + { assert!(open(name, &bytes).is_none(), "{name}"); let s = open_bytes(name, bytes.into(), &[]).unwrap(); assert_eq!(s.info().kind, MediaKind::Still, "{name}"); @@ -129,7 +239,8 @@ fn damaged_files_never_panic() { let frames: Vec<_> = (0..6u8).map(|i| (picture([i * 40, 0, 255 - i * 40], i * 40), (1, 10))).collect(); let png_bytes = apng(&frames); let gif_bytes = gif(&frames.iter().map(|(p, _)| (p.clone(), 100)).collect::>()); - for whole in [png_bytes, gif_bytes] { + let webp_bytes = webp(&frames.iter().map(|(p, _)| (p.clone(), 100)).collect::>()); + for whole in [png_bytes, gif_bytes, webp_bytes] { for cut in (0..whole.len()).step_by(7) { // truncated: opens as fewer frames, a still or an error, never a panic let _ = open_bytes("cut", whole[..cut].to_vec().into(), &[]); @@ -166,6 +277,8 @@ fn frame_delays_have_browser_minimums_and_never_overflow() { assert_eq!(Format::Apng.frame_ticks(d(40, 1)), ms(40)); assert_eq!(Format::Apng.frame_ticks(d(1000, 3)), Tick(TICKS_PER_SECOND / 3)); assert!(Format::Apng.frame_ticks(d(u32::MAX, 1)).0 > 0); + assert_eq!(Format::WebP.frame_ticks(d(0, 1)), ms(10)); + assert_eq!(Format::WebP.frame_ticks(d(40, 1)), ms(40)); } #[test] diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index f875705ed..069c550a8 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -203,7 +203,7 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result Date: Sat, 24 Oct 2026 07:24:34 -0700 Subject: [PATCH 109/110] Timeline: the work area bar, with Set Work Area Bar In and Out Point (#3087) Sequences already store a work area, and Export > Range lists Work Area, but no command set one, so the Work Area range was grayed out. The Timeline panel menu's Work Area Bar now shows the bar over the whole sequence or hides it. Alt+[ and Alt+] in the Timeline move its start and end to the playhead. On the ruler the bar's ends and middle can be dragged, a double-click fits it to the part of the sequence the ruler shows, and its tooltip shows the start, end and duration. The new commands are sequence.workAreaBar, sequence.setWorkAreaIn, sequence.setWorkAreaOut and sequence.setWorkArea. The two keys move from the skipped table in docs/keyboard.md to the keyboard-only commands. --- crates/engine/src/commands.rs | 75 +++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/shortcut_presets.rs | 2 + crates/engine/src/work_area_tests.rs | 116 +++++++++++++ crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/zh-cn.tsv | 4 + crates/ui-egui/src/panels/mod.rs | 8 + crates/ui-egui/src/panels/timeline.rs | 74 ++++++++- crates/ui-egui/src/panels/timeline_hit.rs | 58 +++++++ crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/timeline_work_area_ui.rs | 153 ++++++++++++++++++ docs/control-protocol.md | 2 +- docs/keyboard.md | 2 +- docs/roadmap.md | 2 +- 15 files changed, 502 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/work_area_tests.rs create mode 100644 crates/ui-egui/tests/it/timeline_work_area_ui.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 079f6c6db..813185a12 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2295,6 +2295,55 @@ fn build() -> Vec { crate::previews::delete(s, true) }), query!("sequence.renderBar", "Render Bar", "{}", |s, _| crate::previews::bar_json(s)), + // ================= Work area bar ================= + cmd!("sequence.workAreaBar", "Work Area Bar", [], None, r#"{"show":bool?}"#, has_seq, |s, p| { + let shown = s.active_sequence().ok_or(EngineError::NoSequence)?.work_area.is_some(); + let show = bool_p(p, "show").unwrap_or(!shown); + if show == shown { + return Ok(json!({"show": show})); + } + let fd = s.sequence_rate().frame_duration(); + s.edit_sequence("Work Area Bar", |q, _, _| { + // shown again, the bar spans the whole sequence + q.work_area = show.then(|| TimeRange::from_bounds(Tick::ZERO, q.duration().max(fd))); + Ok(()) + })?; + Ok(json!({"show": show})) + }), + cmd!( + "sequence.setWorkAreaIn", + "Set Work Area Bar In Point", + [], + None, + r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#, + has_seq, + |s, p| set_work_area_point(s, p, true) + ), + cmd!( + "sequence.setWorkAreaOut", + "Set Work Area Bar Out Point", + [], + None, + r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#, + has_seq, + |s, p| set_work_area_point(s, p, false) + ), + cmd!("sequence.setWorkArea", "Set Work Area", [], None, r#"{"start":ticks,"end":ticks}"#, has_seq, |s, p| { + const CMD: &str = "sequence.setWorkArea"; + let tick = |key: &str| p.get(key).and_then(Value::as_i64).map(Tick).ok_or_else(|| bad(CMD, format!("need `{key}` (ticks)"))); + let (start, end) = (tick("start")?, tick("end")?); + if start < Tick::ZERO { + return Err(bad(CMD, "`start` must not be negative")); + } + if end <= start { + return Err(bad(CMD, "`end` must be after `start`")); + } + s.edit_sequence("Set Work Area", |q, _, _| { + q.work_area = Some(TimeRange::from_bounds(start, end)); + Ok(()) + })?; + Ok(json!({"start": start.0, "end": end.0})) + }), cmd!("sequence.matchFrame", "Match Frame", ["Sequence"], Some("F"), "{}", has_seq, |s, _| { let t = s.playhead(); let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; @@ -3640,6 +3689,32 @@ fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result { Ok(Value::Null) } +/// Set Work Area Bar In / Out Point: the work area starts (`is_in`) or ends at `time`, else at the +/// playhead. An In point at or after the end moves the end to the end of the sequence, or to one +/// frame after the In point when the In point is at or past the end of the sequence; an Out point +/// at or before the start moves the start to 0. With no work area yet, the other end is the end +/// or the start of the sequence. +fn set_work_area_point(s: &mut Session, p: &Value, is_in: bool) -> Result { + let cmd = if is_in { "sequence.setWorkAreaIn" } else { "sequence.setWorkAreaOut" }; + let t = sequence_time_p(s, p, "", cmd)?.unwrap_or(s.playhead()); + let fd = s.sequence_rate().frame_duration(); + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (start, end) = seq.work_area.map(|w| (w.start, w.end())).unwrap_or((Tick::ZERO, seq.duration())); + let (start, end) = if is_in { + (t, if end > t { end } else { seq.duration().max(t + fd) }) + } else { + if t <= Tick::ZERO { + return Err(bad(cmd, "the Out point must be after the start of the sequence")); + } + (if start < t { start } else { Tick::ZERO }, t) + }; + s.edit_sequence(if is_in { "Set Work Area Bar In Point" } else { "Set Work Area Bar Out Point" }, |q, _, _| { + q.work_area = Some(TimeRange::from_bounds(start, end)); + Ok(()) + })?; + Ok(json!({"start": start.0, "end": end.0})) +} + pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option { Some(match template { ParamValue::Float(_) => ParamValue::Float(v.as_f64()?), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f711ad190..580098090 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1372,6 +1372,8 @@ mod trim_tests; mod vfx_tests; #[cfg(test)] mod voiceover_tests; +#[cfg(test)] +mod work_area_tests; #[cfg(test)] mod source_placement_tests; diff --git a/crates/engine/src/shortcut_presets.rs b/crates/engine/src/shortcut_presets.rs index 14bd2bf2a..0fe87fe33 100644 --- a/crates/engine/src/shortcut_presets.rs +++ b/crates/engine/src/shortcut_presets.rs @@ -290,6 +290,8 @@ pub const PREMIERE_PANEL: &[Entry] = &[ ("timeline.decreaseAudioHeight", "Alt+-", "Timeline"), ("timeline.nextScreen", "PageDown", "Timeline"), ("timeline.prevScreen", "PageUp", "Timeline"), + ("sequence.setWorkAreaIn", "Alt+[", "Timeline"), + ("sequence.setWorkAreaOut", "Alt+]", "Timeline"), ("graphics.nudgeLeft", "Cmd+Left", "Program"), ("graphics.nudgeRight", "Cmd+Right", "Program"), ("graphics.nudgeUp", "Cmd+Up", "Program"), diff --git a/crates/engine/src/work_area_tests.rs b/crates/engine/src/work_area_tests.rs new file mode 100644 index 000000000..122cb9f7d --- /dev/null +++ b/crates/engine/src/work_area_tests.rs @@ -0,0 +1,116 @@ +//! The work area bar: the Timeline panel menu's Work Area Bar shows and hides it, Set Work Area Bar +//! In / Out Point (Alt+[ / Alt+] in the Timeline) move its ends to the playhead, `sequence.setWorkArea` +//! sets both ends (a drag of the bar), and Export ▸ Range ▸ Work Area exports it. + +use filmcraft_time::{Tick, TimeRange}; +use serde_json::json; + +use crate::Session; +use crate::shortcuts::{Chord, Platform}; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn area(s: &Session) -> Option<(Tick, Tick)> { + s.active_sequence().unwrap().work_area.map(|w| (w.start, w.end())) +} + +#[test] +fn the_work_area_bar_spans_the_sequence_when_shown_and_undoes() { + let mut s = demo(); + let end = s.active_sequence().unwrap().duration(); + assert!(end > Tick::ZERO); + assert_eq!(area(&s), None, "the bar starts hidden"); + assert!(s.execute("export.resolve", json!({"range": "workArea"})).is_err(), "no work area to export"); + + assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": true})); + assert_eq!(area(&s), Some((Tick::ZERO, end))); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Work Area Bar")); + let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap(); + let whole = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "entire"})).unwrap()["frames"].as_i64().unwrap(); + assert_eq!(frames, whole, "the work area covers the whole sequence"); + + // asking for the state it is already in changes nothing + let steps = s.history.undo.len(); + assert_eq!(s.execute("sequence.workAreaBar", json!({"show": true})).unwrap(), json!({"show": true})); + assert_eq!(s.history.undo.len(), steps); + + assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": false})); + assert_eq!(area(&s), None); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(area(&s), Some((Tick::ZERO, end))); +} + +#[test] +fn set_work_area_in_and_out_points_follow_the_playhead() { + let mut s = demo(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let end = s.active_sequence().unwrap().duration(); + + // with no bar yet, the In point runs to the end of the sequence + s.set_playhead(f(24)); + assert_eq!(s.execute("sequence.setWorkAreaIn", json!({})).unwrap(), json!({"start": f(24).0, "end": end.0})); + assert_eq!(area(&s), Some((f(24), end))); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar In Point")); + + s.set_playhead(f(72)); + s.execute("sequence.setWorkAreaOut", json!({})).unwrap(); + assert_eq!(area(&s), Some((f(24), f(72)))); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar Out Point")); + let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap(); + assert!((47..=49).contains(&frames), "{frames}"); + + // an In point past the Out point moves the Out point to the end of the sequence + s.execute("sequence.setWorkAreaIn", json!({"frame": 96})).unwrap(); + assert_eq!(area(&s), Some((f(96), end))); + // an Out point before the In point moves the In point to the start + s.execute("sequence.setWorkAreaOut", json!({"frame": 48})).unwrap(); + assert_eq!(area(&s), Some((Tick::ZERO, f(48)))); + // an In point at or past the end of the sequence keeps one frame + s.execute("sequence.setWorkAreaIn", json!({"time": end.0})).unwrap(); + assert_eq!(area(&s), Some((end, end + rate.frame_duration()))); + + let before = area(&s); + assert!(s.execute("sequence.setWorkAreaOut", json!({"time": 0})).is_err(), "an Out point at 0 leaves no work area"); + assert!(s.execute("sequence.setWorkAreaIn", json!({"time": -1})).is_err(), "negative times are refused"); + assert_eq!(area(&s), before); +} + +#[test] +fn set_work_area_takes_both_ends_and_refuses_bad_ones() { + let mut s = demo(); + let rate = s.sequence_rate(); + let (a, b) = (rate.tick_of(10), rate.tick_of(20)); + assert_eq!(s.execute("sequence.setWorkArea", json!({"start": a.0, "end": b.0})).unwrap(), json!({"start": a.0, "end": b.0})); + assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b))); + for p in [ + json!({"start": b.0, "end": a.0}), + json!({"start": a.0, "end": a.0}), + json!({"start": -1, "end": b.0}), + json!({"start": a.0}), + json!({"end": b.0}), + json!({"start": 0, "end": i64::MAX}), + ] { + assert!(s.execute("sequence.setWorkArea", p.clone()).is_err(), "{p}"); + assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b)), "{p}"); + } + let mut empty = Session::default(); + for id in ["sequence.workAreaBar", "sequence.setWorkAreaIn", "sequence.setWorkAreaOut", "sequence.setWorkArea"] { + assert!(empty.execute(id, json!({"start": 0, "end": 1})).is_err(), "{id} needs a sequence"); + } +} + +#[test] +fn alt_brackets_set_the_work_area_in_the_timeline() { + let s = Session::default(); + for p in [Platform::Mac, Platform::Windows] { + for (keys, cmd) in [("Alt+[", "sequence.setWorkAreaIn"), ("Alt+]", "sequence.setWorkAreaOut")] { + let chord = Chord::parse(keys).unwrap(); + assert_eq!(s.shortcuts.resolve(&chord, Some("Timeline"), p).map(|b| b.command.as_str()), Some(cmd), "{keys} {p:?}"); + } + } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 19fbf457c..b13e79d6e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -511,6 +511,10 @@ Reveal Sequence in Project Mostrar secuencia en el proyecto Video Thumbnails Miniaturas de video Audio Waveforms Formas de onda de audio + Work Area Bar Barra de área de trabajo + Start: {tc} Inicio: {tc} + End: {tc} Fin: {tc} + Duration: {tc} Duración: {tc} Delete Preset Eliminar ajuste preestablecido Export Preset… Exportar ajuste preestablecido… Import Presets… Importar ajustes preestablecidos… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fac3d1d9d..eacaa51a2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -508,6 +508,10 @@ Reveal Sequence in Project プロジェクトでシーケンスを表示 Video Thumbnails ビデオサムネイル Audio Waveforms オーディオ波形 + Work Area Bar ワークエリアバー + Start: {tc} 開始: {tc} + End: {tc} 終了: {tc} + Duration: {tc} 長さ: {tc} Delete Preset プリセットを削除 Export Preset… プリセットを書き出し… Import Presets… プリセットを読み込み… diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 262632a97..3e3e2f5bf 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -502,6 +502,10 @@ Reveal Sequence in Project 在项目面板中显示序列 Video Thumbnails 视频缩略图 Audio Waveforms 音频波形 + Work Area Bar 工作区域栏 + Start: {tc} 开始:{tc} + End: {tc} 结束:{tc} + Duration: {tc} 持续时间:{tc} Delete Preset 删除预设 Export Preset… 导出预设… Import Presets… 导入预设… diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 532f8db17..68bc2d2ff 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -339,6 +339,14 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { } ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); + let mut work_area = app.session.active_sequence().is_some_and(|q| q.work_area.is_some()); + let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Checkbox::new(&mut work_area, tl!("Work Area Bar"))); + app.auto.add("panel.menu.Timeline.workAreaBar", r.rect, "Work Area Bar"); + if r.changed() + && let Err(e) = app.session.execute("sequence.workAreaBar", serde_json::json!({"show": work_area})) + { + app.ui.status = e.to_string(); + } } if p == PanelKind::Project { ui.separator(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 5e9c666b8..3d59a0c6c 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -14,7 +14,7 @@ use std::sync::{Arc, Mutex}; use egui::{Align2, Color32, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use filmcraft_project::{ClipId, ItemId, MarkerId, Sequence, TrackId, TrackItem, TrackKind}; -use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeDisplay, format_time}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeDisplay, TimeRange, format_time}; use serde_json::{Value, json}; use crate::FilmcraftApp; @@ -22,7 +22,7 @@ use crate::icons::{self, Icon}; use crate::state::{ThumbnailMode, TimelineView, Tool}; use crate::theme::Tokens; -use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at, marker_at, marker_range_rect, marker_rect, marker_under_pointer}; +use super::timeline_hit::{EDGE_PX, EdgeKind, Grab, edge_geometry, grab_at, marker_at, marker_range_rect, marker_rect, marker_under_pointer, work_area_rect}; pub use super::timeline_hit::{Hit, ROLL_PX, hit, selection_trim_kind}; const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right) @@ -157,6 +157,15 @@ pub enum Drag { start: Tick, duration: Tick, }, + /// The work area bar dragged by an end (`edge`: that end moves) or its middle (the bar moves); + /// `start` / `end` are where it is shown now, committed on release. + WorkArea { + edge: Option, + /// As in [`Drag::Trim`]. + from: Tick, + start: Tick, + end: Tick, + }, Pan { last: Pos2, start_scroll: f64, @@ -1620,6 +1629,27 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc }); } } + // work area bar: between the markers row and the time labels, as dragged while a drag of it runs + let shown = match app.tl.drag { + Some(Drag::WorkArea { start, end, .. }) => Some(TimeRange::from_bounds(start, end)), + _ => seq.work_area, + }; + if let Some(area) = shown { + let r = work_area_rect(layout, area); + clip.rect_filled(r, 1.0, t.text_dim); + for x in [r.min.x, r.max.x] { + clip.rect_filled(Rect::from_center_size(pos2(x, r.center().y), vec2(3.0, r.height() + 2.0)), 1.0, t.text); + } + app.auto.add("timeline.workArea", r.intersect(ruler), "Work Area Bar"); + let fmt = |tick: Tick| format_time(tick, rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000); + if ui.rect_contains_pointer(r.expand2(vec2(EDGE_PX, 1.0)).intersect(ruler)) { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("work-area-tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(tlf!("Start: {tc}", tc = fmt(area.start))); + ui.label(tlf!("End: {tc}", tc = fmt(area.end()))); + ui.label(tlf!("Duration: {tc}", tc = fmt(area.duration))); + }); + } + } // markers: 8 × 12 pt pentagons in the marker colour, on the top row for m in &seq.markers { let x = layout.x_of(m.start); @@ -1835,6 +1865,7 @@ pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value { }; match grab.hit() { Hit::Ruler => json!({"hit": "ruler", "time": t.0}), + Hit::WorkArea { edge } => json!({"hit": "workArea", "edge": edge.map(|e| format!("{e:?}")), "time": t.0}), Hit::Clip { track, clip, edge } => { json!({"hit": "clip", "track": track.0, "clip": clip.0, "edge": edge.map(|e| format!("{e:?}")), "kind": kind, "time": t.0}) } @@ -2348,6 +2379,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & (_, Grab::Edge { .. }) => CursorIcon::ResizeColumn, (Tool::Selection, Grab::Other(Hit::Transition { edge: Some(_), .. })) => CursorIcon::ResizeColumn, (Tool::Selection, Grab::Other(Hit::Transition { edge: None, .. })) => CursorIcon::Grab, + (_, Grab::Other(Hit::WorkArea { edge: Some(_) })) => CursorIcon::ResizeColumn, + (_, Grab::Other(Hit::WorkArea { edge: None })) => CursorIcon::Grab, (_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair, (_, Grab::Volume { .. }) => CursorIcon::ResizeVertical, (Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair, @@ -2399,6 +2432,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & Some(Drag::Pan { last, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }) } (_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub { initial: app.session.playhead() }), + (_, Grab::Other(Hit::WorkArea { edge })) => { + seq.work_area.filter(|_| resp.drag_started()).map(|w| Drag::WorkArea { edge, from: t, start: w.start, end: w.end() }) + } (Tool::Hand, _) => Some(Drag::Pan { last: p, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }), (Tool::Zoom, _) => { if resp.clicked() { @@ -2674,6 +2710,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let (start, duration) = span.unwrap_or((start, duration)); Some(Drag::Transition { id, edge, from, start, duration }) } + Drag::WorkArea { edge, from, start, end } => { + // the bar as it was at the press: the sequence keeps it until the release + let (s0, e0) = seq.work_area.map(|w| (w.start, w.end())).unwrap_or((start, end)); + let fd = rate.frame_duration(); + let delta = rate.snap_nearest(t_here - from); + let (start, end) = match edge { + Some(filmcraft_edit::Edge::In) => (snap(app, seq, layout, rate.snap_nearest(s0 + delta), &[]).min(e0 - fd).max(Tick::ZERO), e0), + Some(filmcraft_edit::Edge::Out) => (s0, snap(app, seq, layout, rate.snap_nearest(e0 + delta), &[]).max(s0 + fd)), + None => { + let d = delta.max(Tick::ZERO - s0); + (s0 + d, e0 + d) + } + }; + Some(Drag::WorkArea { edge, from, start, end }) + } Drag::Volume(v) => Some(Drag::Volume(super::timeline_volume::drag(app, ui, seq, layout, v, p.y))), Drag::Marquee { start, before, add, individual } => { let r = Rect::from_two_pos(start, p); @@ -2746,6 +2797,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let moved = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).is_some_and(|x| (x.start, x.duration) != (start, duration)); moved.then(|| app.session.execute("sequence.setTransition", json!({"transition": id.0, "start": start.0, "duration": duration.0}))) } + Drag::WorkArea { start, end, .. } => { + let moved = seq.work_area.is_some_and(|w| (w.start, w.end()) != (start, end)); + moved.then(|| app.session.execute("sequence.setWorkArea", json!({"start": start.0, "end": end.0}))) + } Drag::Marquee { start, before, add, individual } => { app.session.state.selection = before; // egui ends a drag on Escape: that cancels the box, as in the Project panel @@ -2767,6 +2822,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } + // ---- double-click the work area bar: it spans the part of the sequence the ruler shows + if resp.double_clicked() + && let Some(p) = resp.interact_pointer_pos() + && let Hit::WorkArea { .. } = hit(seq, layout, p) + { + let start = rate.snap_nearest(layout.tick_at(layout.content.min.x).max(Tick::ZERO)); + let end = rate.snap_nearest(layout.tick_at(layout.content.max.x)).min(seq.duration()); + // a ruler scrolled past the end of the sequence leaves the bar as it is + if end > start + && let Err(e) = app.session.execute("sequence.setWorkArea", json!({"start": start.0, "end": end.0})) + { + app.ui.status = e.to_string(); + } + } + // ---- double-click a transition: Set Transition Duration (Premiere) if resp.double_clicked() && tool == Tool::Selection diff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs index f94e9be92..95a7fe313 100644 --- a/crates/ui-egui/src/panels/timeline_hit.rs +++ b/crates/ui-egui/src/panels/timeline_hit.rs @@ -51,6 +51,11 @@ pub(crate) fn edge_geometry(seq: &Sequence, layout: &Layout, track: TrackId, cli #[derive(Clone, Copy, Debug, PartialEq)] pub enum Hit { Ruler, + /// The work area bar on the ruler; `edge` is its start (`In`) or end (`Out`) when the press is + /// on one of its ends (dragging it moves that end), `None` on its middle (dragging moves it). + WorkArea { + edge: Option, + }, Clip { track: TrackId, clip: ClipId, @@ -96,8 +101,35 @@ pub fn marker_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option { marker_under_pointer(seq, layout, pos).map(|m| m.start) } +/// Where the Timeline ruler draws (and hit-tests) the work area bar, between the markers row and +/// the time labels. +pub fn work_area_rect(layout: &Layout, area: filmcraft_time::TimeRange) -> Rect { + let y = layout.ruler.min.y + 14.0; + Rect::from_min_max(pos2(layout.x_of(area.start), y), pos2(layout.x_of(area.end()), y + 6.0)) +} + +/// The part of the work area bar under the pointer: an end (an edge zone, narrower on a short +/// bar so its middle stays grabbable) or its middle. +pub fn work_area_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option> { + let r = work_area_rect(layout, seq.work_area?); + let zone = zone(r.width())?; + if !r.expand2(vec2(zone, 1.0)).contains(pos) || !layout.ruler.contains(pos) { + return None; + } + Some(if (pos.x - r.min.x).abs() <= zone { + Some(Edge::In) + } else if (r.max.x - pos.x).abs() <= zone { + Some(Edge::Out) + } else { + None + }) +} + pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit { if layout.ruler.contains(pos) { + if let Some(edge) = work_area_at(seq, layout, pos) { + return Hit::WorkArea { edge }; + } return Hit::Ruler; } if !layout.content.contains(pos) { @@ -477,6 +509,32 @@ mod tests { assert_eq!(marker_under_pointer(&seq, &layout, at).unwrap().id.0, 9); } + #[test] + fn the_work_area_bar_has_two_ends_and_a_middle() { + let (mut seq, l, _) = fixture(clips()); + // hidden, the bar's row is plain ruler + assert_eq!(hit(&seq, &l, pos2(300.0, 17.0)), Hit::Ruler); + seq.work_area = Some(filmcraft_time::TimeRange::from_bounds(Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(5.0))); + let y = work_area_rect(&l, seq.work_area.unwrap()).center().y; + assert_eq!(hit(&seq, &l, pos2(197.0, y)), Hit::WorkArea { edge: Some(Edge::In) }); + assert_eq!(hit(&seq, &l, pos2(204.0, y)), Hit::WorkArea { edge: Some(Edge::In) }); + assert_eq!(hit(&seq, &l, pos2(350.0, y)), Hit::WorkArea { edge: None }); + assert_eq!(hit(&seq, &l, pos2(503.0, y)), Hit::WorkArea { edge: Some(Edge::Out) }); + assert_eq!(hit(&seq, &l, pos2(510.0, y)), Hit::Ruler, "past its end zone"); + // the markers row and the time labels stay ruler + assert_eq!(hit(&seq, &l, pos2(350.0, 8.0)), Hit::Ruler); + assert_eq!(hit(&seq, &l, pos2(350.0, 30.0)), Hit::Ruler); + assert_eq!(grab_at(&seq, &l, pos2(350.0, y), Tool::Selection, Modifiers::NONE, false), Grab::Other(Hit::WorkArea { edge: None })); + // a bar a few px wide keeps its middle third; hostile points grab nothing + seq.work_area = Some(filmcraft_time::TimeRange::from_bounds(Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(2.09))); + assert_eq!(hit(&seq, &l, pos2(204.5, y)), Hit::WorkArea { edge: None }); + assert_eq!(hit(&seq, &l, pos2(f32::NAN, y)), Hit::None); + for pps in [0.0, f64::NAN, f64::INFINITY] { + let broken = Layout { pps, ..l.clone() }; + assert!(!matches!(hit(&seq, &broken, pos2(204.5, y)), Hit::WorkArea { edge: Some(_) }), "pps {pps}"); + } + } + use egui::Modifiers; use crate::state::Tool; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 57bfabdf0..b8e5b4409 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -68,6 +68,7 @@ mod timeline_tabs_ui; mod timeline_track_height_ui; mod timeline_transition_move_ui; mod timeline_trim_ui; +mod timeline_work_area_ui; mod track_color_ui; mod track_select_drag_ui; mod transcript_ui; diff --git a/crates/ui-egui/tests/it/timeline_work_area_ui.rs b/crates/ui-egui/tests/it/timeline_work_area_ui.rs new file mode 100644 index 000000000..d04c919f6 --- /dev/null +++ b/crates/ui-egui/tests/it/timeline_work_area_ui.rs @@ -0,0 +1,153 @@ +//! The work area bar on the Timeline ruler: dragging an end moves that end, dragging its middle +//! moves the bar, a double-click fits it to the visible part of the sequence, and none of these +//! moves the playhead. Each gesture is one `sequence.setWorkArea` undo step. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + // 60 frames a second: the two clicks of a double-click are one frame apart each, well + // inside egui's 0.3 s + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn bar(&mut self) -> Option<[f64; 4]> { + let v = self.ok("ui.elements", json!({"prefix": "timeline.workArea"})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == "timeline.workArea")?.clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Some([r[0], r[1], r[2], r[3]]) + } + fn drag(&mut self, from: (f64, f64), dx: f64) { + self.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + dx, "y": from.1}, "steps": 8})); + self.frames(3); + } + fn area(&self) -> Option<(i64, i64)> { + self.harness.state().session.active_sequence().unwrap().work_area.map(|w| (w.start.0, w.end().0)) + } + fn last_undo(&self) -> Option { + self.harness.state().session.history.undo.last().map(|u| u.0.clone()) + } +} + +#[test] +fn dragging_and_double_clicking_the_work_area_bar() { + let mut d = Driver::demo(); + d.exec("sequence.snap", json!({"on": false})); + let rate = d.harness.state().session.sequence_rate(); + let (f24, f72) = (rate.tick_of(24).0, rate.tick_of(72).0); + let frame = rate.frame_duration().0; + assert_eq!(d.bar(), None, "hidden, the bar is not drawn"); + d.exec("sequence.setWorkArea", json!({"start": f24, "end": f72})); + d.frames(2); + let r = d.bar().expect("the bar is drawn once it is set"); + let (y, per_frame) = (r[1] + r[3] / 2.0, r[2] / 48.0); + let playhead = d.harness.state().session.playhead(); + + // the Out end, 24 frames to the right + d.drag((r[0] + r[2] - 1.0, y), 24.0 * per_frame); + let (start, end) = d.area().unwrap(); + assert_eq!(start, f24, "the In end stays"); + assert!((end - rate.tick_of(96).0).abs() <= frame, "the Out end moved 24 frames: {end}"); + assert_eq!(d.last_undo().as_deref(), Some("Set Work Area")); + + // the middle, 12 frames to the left: both ends move, the length stays + let r = d.bar().unwrap(); + d.drag((r[0] + r[2] / 2.0, y), -12.0 * per_frame); + let (start2, end2) = d.area().unwrap(); + assert_eq!(end2 - start2, end - start); + assert!((start2 - rate.tick_of(12).0).abs() <= frame, "the bar moved 12 frames: {start2}"); + assert_eq!(d.harness.state().session.playhead(), playhead, "dragging the bar leaves the playhead"); + + // the In end cannot cross the Out end + let r = d.bar().unwrap(); + d.drag((r[0] + 1.0, y), r[2] + 40.0 * per_frame); + let (start3, end3) = d.area().unwrap(); + assert_eq!((start3, end3), (end2 - frame, end2), "the In end stops one frame before the Out end"); + + // a double-click spans the visible part of the sequence (the whole sequence is in view) + d.exec("sequence.setWorkArea", json!({"start": f24, "end": f72})); + d.ok("ui.menu.invoke", json!({"id": "view.zoomToSequence"})); + d.frames(60); + let r = d.bar().unwrap(); + let hit = d.ok("ui.timeline.hit", json!({"x": r[0] + r[2] / 2.0, "y": y})); + assert_eq!(hit["hit"], "workArea", "the control channel reports the bar: {hit}"); + assert_eq!(hit["edge"], Value::Null, "{hit}"); + d.ok("ui.click", json!({"x": r[0] + r[2] / 2.0, "y": y, "count": 2})); + d.frames(3); + let duration = d.harness.state().session.active_sequence().unwrap().duration().0; + let (start4, end4) = d.area().unwrap(); + assert_eq!(start4, 0); + assert!((end4 - duration).abs() <= frame, "{end4} of {duration}"); + assert_eq!(d.harness.state().session.playhead(), playhead, "nor does a double-click"); + + // hidden again from the Timeline panel menu's command + d.exec("sequence.workAreaBar", json!({"show": false})); + d.frames(2); + assert_eq!(d.bar(), None); +} + +#[test] +fn the_in_end_of_a_work_area_shorter_than_a_frame_stays_at_zero() { + let mut d = Driver::demo(); + d.exec("sequence.snap", json!({"on": false})); + let rate = d.harness.state().session.sequence_rate(); + let frame = rate.frame_duration().0; + // zoomed in about the start of the sequence, so half a frame is a few pixels wide + d.exec("playhead.start", json!({})); + for _ in 0..5 { + d.ok("ui.menu.invoke", json!({"id": "view.zoomIn"})); + } + d.exec("sequence.setWorkArea", json!({"start": rate.tick_of(24).0, "end": rate.tick_of(72).0})); + d.frames(2); + let per_frame = d.bar().unwrap()[2] / 48.0; + assert!(per_frame > 4.0, "a frame is wide enough to grab half of it: {per_frame}"); + d.exec("sequence.setWorkArea", json!({"start": 0, "end": frame / 2})); + d.frames(2); + let r = d.bar().unwrap(); + let y = r[1] + r[3] / 2.0; + d.drag((r[0] + 0.5, y), 10.0 * per_frame); + assert_eq!(d.area(), Some((0, frame / 2)), "the In end stays at 0"); + let status = d.harness.state().ui.status.clone(); + assert!(!status.contains("must not be negative"), "the release sends a valid start: {status}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f5b32a1d9..c21746922 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,7 +56,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.drag` | `{from, to, steps, modifiers}` | press–move–release; `steps` is an integer 0–1024 (default 12; 0 or 1 uses 2 steps) | | `ui.scroll` | `{id|x,y, dx, dy, modifiers}` | wheel / trackpad | | `ui.key` / `ui.type` | `{key}` (`Cmd+K`, `Space`…; off macOS `Ctrl+K` is the same key) / `{text}` | keyboard | -| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null | +| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null. On the ruler it reports `ruler`, or `workArea` with the bar's `edge` on the work area bar | | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | | `ui.resize`, `app.quit` | `{width,height}` / `{}` | resize or close the native window; `app.quit` quits without the save prompt (unsaved changes stay in the recovery journal), while the command `app.quit` asks first | diff --git a/docs/keyboard.md b/docs/keyboard.md index 4ca06cfa8..487d4df12 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -26,6 +26,7 @@ Premiere has these as keys only (most are not in a menu). Modules: `filmcraft_en | Trimming | Extend Previous/Next Edit To Playhead; Nudge Clip Selection Left/Right One/Five Frames, Up/Down; Slip and Slide Clip Selection Left/Right One/Five Frames | Shift+Q/W; Cmd+(Shift+)Left/Right, Alt+Up/Down; Cmd+Alt+(Shift+)Left/Right; Alt+(Shift+), / . (Timeline) | | Targeting | Toggle All Video/Audio Targets, Toggle All Source Video/Audio; Toggle Target Video/Audio 1–8, Move All Video/Audio Targets Up/Down, Toggle Mute/Solo for targeted audio, Toggle Track Output for targeted video (unbound, as in Premiere) | Cmd+0/9, Cmd+Alt+0/9 | | Tracks | Expand/Minimize All Tracks (again restores), Increase/Decrease Video/Audio Tracks Height, Show Next/Previous Screen | Shift+= / Shift+-, Cmd+= / Cmd+- / Alt+= / Alt+-, PageDown/PageUp (Timeline) | +| Work area | Set Work Area Bar In/Out Point (`sequence.setWorkAreaIn` / `setWorkAreaOut` in `filmcraft_engine::commands`; the Timeline panel menu's Work Area Bar shows and hides the bar) | Alt+[ / Alt+] (Timeline) | | Monitors | Zoom Program/Source Monitor to 100%/Fit, Toggle Full Screen, Play In to Out with Preroll/Postroll, Play from Playhead to Out Point, Export Frame, Set/Clear Poster Frame | Cmd+Shift+1/0, Cmd+Alt+Shift+1/0, Ctrl+`, Shift+Space, Ctrl+Space, Shift+E, Cmd+P / Alt+P | | Panels | Maximize or Restore Active Frame / Frame Under Cursor, Select Next/Previous Panel, Toggle Source/Program Monitor Focus, Workspaces 7–9 | Shift+`, `, Ctrl+Shift+. / ,, Alt+Shift+7/8/9 | | Audio | Increase/Decrease Clip Volume (1 dB) and …Many (Settings ▸ Audio ▸ Large Volume Adjustment), Nudge Volume ±1/±3 dB, Toggle Audio During Scrubbing | ] / [, Shift+] / Shift+[, Shift+S | @@ -61,4 +62,3 @@ Frame has no default key; the Premiere preset moves Shift+E to Export Frame and | Project panel: Next/Previous Column Field, Next/Previous Row Field (Tab, ⇧Tab, Return, ⇧Return) | The list view has no inline-editable metadata cells. | | Text panel: Edit Segment (Return) | No inline transcript text editing (corrections go through `transcript.set`). | | Text panel: Follow Active Monitor (⇧C), Show Source Transcript (⇧Z) | Only sequence transcripts exist; source clips have no transcript view. | -| Timeline: Set Work Area Bar In/Out Point (⌥[ / ⌥]) | FilmCraft has no work area bar (Premiere hides it by default; renders use In/Out). | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6f419b182..ae2e49261 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,7 +63,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining | M0 | Skeleton + visual shell | ✅ | Workspace, 20 crates, dock/workspaces, Premiere 26 look, native menus, control channel, MCP, xtask gates (layers, wasm) | — | — | | M1 | Media I/O | ✅ | MP4/MOV demux+mux, WAV, stills, MJPEG, symphonia audio (MP3/FLAC/ALAC/Vorbis), GOP seek + frame cache, import | Media Browser polish | 2 | | M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — | -| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); M3.12 keyboard parity: Premiere's default keyboard (~115 keyboard-only commands: edit-point navigation on targeted/any track, select clip at playhead/next/previous, extend edit to playhead, nudge/slip/slide selection, target and source-patch toggles, clip volume ±1 dB/many, frame maximize/full screen/panel cycling, monitor zoom, track heights, Project and Text panel keyboard navigation, text size/leading/alignment, Export Frame, poster frames; see docs/keyboard.md); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), 39 Premiere default shortcuts skipped with reasons in docs/keyboard.md (Productions, AI/cloud tools, work area bar, Production/Search panels) | 6–10 | +| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); M3.12 keyboard parity: Premiere's default keyboard (~115 keyboard-only commands: edit-point navigation on targeted/any track, select clip at playhead/next/previous, extend edit to playhead, nudge/slip/slide selection, target and source-patch toggles, clip volume ±1 dB/many, frame maximize/full screen/panel cycling, monitor zoom, track heights, Project and Text panel keyboard navigation, text size/leading/alignment, Export Frame, poster frames; see docs/keyboard.md); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), 34 Premiere default shortcuts skipped with reasons in docs/keyboard.md (Productions, AI/cloud tools, Production/Search panels) | 6–10 | | M4 | Playback | 🟡 | Audio-clock master, prefetch with cancellation, J/K/L, correct dropped-frame stats, playback resolution, render bar + content-hashed render previews, App Nap opt-out; 1080p H.264 and 3 stacked 1080p streams play with 0 dropped frames | 4K under load, 8K, frame-threaded AV1 decode, reduced-resolution decode for multicam grids | 6–10 | | 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 | From bfc23840b603e69c782302c6ed04d4a9f6ec26a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 24 Oct 2026 10:25:26 -0400 Subject: [PATCH 110/110] relink tests: no unused index_folder import on Windows (#3113) The only use is in a #[cfg(unix)] symlink test, so clippy -D warnings flagged the import on Windows. Name the function at its use instead. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/relink_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index 180629a2a..7660f85ba 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -9,7 +9,7 @@ use serde_json::json; use crate::Session; use crate::media_test_util::{frame_rgba, make_movie, psnr, session_with, tmp_dir}; -use crate::relink::{apply_remap, derive_remap, index_folder}; +use crate::relink::{apply_remap, derive_remap}; const W: u32 = 160; const H: u32 = 90; @@ -178,7 +178,7 @@ fn relink_search_does_not_follow_directory_symlinks() { symlink(&outside, inside.join("loop")).unwrap(); symlink(outside.join("outside.mov"), inside.join("linked.mov")).unwrap(); - let index = index_folder(&inside, 20_000); + let index = crate::relink::index_folder(&inside, 20_000); assert!(index.contains_key("kept.mov")); assert!(!index.contains_key("outside.mov")); // a symlinked file is still media the user can relink to